notebooks: query input for file block (#32506)

This commit is contained in:
Rok Novosel 2022-03-16 08:21:36 +01:00 committed by GitHub
parent 70a8af55d9
commit 4abf00bde6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 763 additions and 1198 deletions

View File

@ -22,6 +22,7 @@ export function useQueryIntelligence(
globbing: boolean
interpretComments?: boolean
isSourcegraphDotCom?: boolean
disablePatternSuggestions?: boolean
}
): string {
// Due to the global nature of Monaco (tokens, hover, completion) providers we have to create a unique
@ -38,8 +39,15 @@ export function useQueryIntelligence(
globbing: options.globbing,
interpretComments: options.interpretComments,
isSourcegraphDotCom: options.isSourcegraphDotCom,
disablePatternSuggestions: options.disablePatternSuggestions,
}),
[options.patternType, options.globbing, options.interpretComments, options.isSourcegraphDotCom]
[
options.patternType,
options.globbing,
options.interpretComments,
options.isSourcegraphDotCom,
options.disablePatternSuggestions,
]
)
useEffect(() => {

View File

@ -42,7 +42,7 @@ const MAX_SUGGESTION_COUNT = 50
const REPO_SUGGESTION_FILTERS = [FilterType.fork, FilterType.visibility, FilterType.archived]
const FILE_SUGGESTION_FILTERS = [...REPO_SUGGESTION_FILTERS, FilterType.repo, FilterType.rev, FilterType.lang]
export function getSuggestionQuery(tokens: Token[], tokenAtColumn: Token): string {
export function getSuggestionQuery(tokens: Token[], tokenAtColumn: Token, disablePatternSuggestions?: boolean): string {
const hasAndOrOperators = tokens.some(
token => token.type === 'keyword' && (token.kind === KeywordKind.Or || token.kind === KeywordKind.And)
)
@ -66,7 +66,7 @@ export function getSuggestionQuery(tokens: Token[], tokenAtColumn: Token): strin
const relevantFilters = serializeFilters(tokens, FILE_SUGGESTION_FILTERS)
return `${relevantFilters} file:${tokenAtColumn.value.value} type:path count:${MAX_SUGGESTION_COUNT}`.trimStart()
}
if (tokenAtColumn.type === 'pattern' && tokenAtColumn.value) {
if (tokenAtColumn.type === 'pattern' && tokenAtColumn.value && !disablePatternSuggestions) {
const relevantFilters = serializeFilters(tokens, [...FILE_SUGGESTION_FILTERS, FilterType.file])
return `${relevantFilters} ${tokenAtColumn.value} type:symbol count:${MAX_SUGGESTION_COUNT}`.trimStart()
}
@ -87,6 +87,7 @@ export function getProviders(
options: {
patternType: SearchPatternType
globbing: boolean
disablePatternSuggestions?: boolean
interpretComments?: boolean
isSourcegraphDotCom?: boolean
}
@ -134,7 +135,7 @@ export function getProviders(
return null
}
return of(getSuggestionQuery(scanned.term, tokenAtColumn))
return of(getSuggestionQuery(scanned.term, tokenAtColumn, options.disablePatternSuggestions))
.pipe(
// We use a delay here to implement a custom debounce. In the next step we check if the current
// completion request was cancelled in the meantime (`token.isCancellationRequested`).

View File

@ -149,6 +149,21 @@ const mockSymbolStreamEvents: SearchEvent[] = [
{ type: 'done', data: {} },
]
const mockFilePathStreamEvents: SearchEvent[] = [
{
type: 'matches',
data: [
{
type: 'path',
repository: 'github.com/sourcegraph/sourcegraph',
path: 'client/web/index.ts',
commit: 'branch',
},
],
},
{ type: 'done', data: {} },
]
const commonSearchGraphQLResults: Partial<WebGraphQlOperations & SharedGraphQlOperations> = {
...commonWebGraphQlResults,
...highlightFileResult,
@ -303,6 +318,8 @@ describe('Search Notebook', () => {
})
it('Should add file block and edit it', async () => {
testContext.overrideSearchStreamEvents(mockFilePathStreamEvents)
await driver.page.goto(driver.sourcegraphBaseUrl + '/notebooks/n1')
await driver.page.waitForSelector('[data-block-id]', { visible: true })
@ -314,39 +331,42 @@ describe('Search Notebook', () => {
const fileBlockSelector = blockSelector(blockIds[2])
// Edit new file block
await driver.page.click(fileBlockSelector)
await driver.page.click(`${fileBlockSelector} .monaco-editor`)
await driver.replaceText({
selector: `${fileBlockSelector} [data-testid="file-block-repository-name-input"]`,
newText: 'github.com/sourcegraph/sourcegraph',
selectMethod: 'keyboard',
enterTextMethod: 'paste',
})
// Wait for input to validate
await driver.page.waitForSelector(
`${fileBlockSelector} [data-testid="file-block-repository-name-input"].is-valid`
)
await driver.replaceText({
selector: `${fileBlockSelector} [data-testid="file-block-file-path-input"]`,
selector: `${fileBlockSelector} .monaco-editor`,
newText: 'client/web/file.tsx',
selectMethod: 'keyboard',
enterTextMethod: 'paste',
})
// Wait for input to validate
await driver.page.waitForSelector(`${fileBlockSelector} [data-testid="file-block-file-path-input"].is-valid`)
// Wait for highlighted code to load
await driver.page.waitForSelector(`${fileBlockSelector} td.line`, { visible: true })
// Wait for file suggestion button and click it
await driver.page.waitForSelector(`${fileBlockSelector} [data-testid="file-suggestion-button"]`, {
visible: true,
})
await driver.page.click(`${fileBlockSelector} [data-testid="file-suggestion-button"]`)
// Refocus the entire block (prevents jumping content for below actions)
await driver.page.click(fileBlockSelector)
await driver.replaceText({
selector: `[id="${blockIds[2]}-line-range-input"]`,
newText: '1-20',
selectMethod: 'keyboard',
enterTextMethod: 'paste',
})
// Wait for header to update to load
await driver.page.waitForFunction(
(fileBlockSelector: string) => {
const fileBlockHeaderSelector = `${fileBlockSelector} [data-testid="file-block-header"]`
return document.querySelector<HTMLDivElement>(fileBlockHeaderSelector)?.textContent?.includes('#')
},
{},
fileBlockSelector
)
// Save the inputs
await driver.page.click('[data-testid="Save"]')
const fileBlockHeaderText = await getFileBlockHeaderText(fileBlockSelector)
expect(fileBlockHeaderText).toEqual('github.com/sourcegraph/sourcegraph/client/web/file.tsx')
expect(fileBlockHeaderText).toEqual('client/web/index.ts#1-20github.com/sourcegraph/sourcegraph@branch')
})
it('Should add file block and auto-fill the inputs when pasting a file URL', async () => {
@ -389,9 +409,7 @@ describe('Search Notebook', () => {
await driver.page.click('[data-testid="Save"]')
const fileBlockHeaderText = await getFileBlockHeaderText(fileBlockSelector)
expect(fileBlockHeaderText).toEqual(
'github.com/sourcegraph/sourcegraph/client/search/src/index.ts@main, lines 30-32'
)
expect(fileBlockHeaderText).toEqual('client/search/src/index.ts#30-32github.com/sourcegraph/sourcegraph@main')
})
it('Should update the notebook title', async () => {

View File

@ -1,21 +1,23 @@
import React, { useRef } from 'react'
import React, { useEffect, useMemo } from 'react'
import classNames from 'classnames'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { BlockProps } from '..'
import { isModifierKeyPressed } from '../notebook/useNotebookEventHandlers'
import { NotebookBlockMenu, NotebookBlockMenuProps } from './menu/NotebookBlockMenu'
import { useBlockSelection } from './useBlockSelection'
import { useBlockShortcuts } from './useBlockShortcuts'
import { useIsBlockInputFocused } from './useIsBlockInputFocused'
import blockStyles from './NotebookBlock.module.scss'
interface NotebookBlockProps extends Omit<BlockProps, 'input' | 'output'>, NotebookBlockMenuProps {
interface NotebookBlockProps extends Pick<BlockProps, 'isSelected' | 'isOtherBlockSelected'>, NotebookBlockMenuProps {
className?: string
isInputFocused: boolean
'aria-label': string
onDoubleClick?: () => void
onEnterBlock: () => void
onHideInput?: () => void
}
export const NotebookBlock: React.FunctionComponent<NotebookBlockProps> = ({
@ -23,30 +25,32 @@ export const NotebookBlock: React.FunctionComponent<NotebookBlockProps> = ({
id,
className,
isSelected,
isInputFocused,
isOtherBlockSelected,
mainAction,
actions,
'aria-label': ariaLabel,
onEnterBlock,
onDoubleClick,
...props
onEnterBlock,
onHideInput,
}) => {
const blockElement = useRef(null)
const isInputFocused = useIsBlockInputFocused(id)
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (isSelected && event.key === 'Enter') {
if (isModifierKeyPressed(event.metaKey, event.ctrlKey, isMacPlatform)) {
onHideInput?.()
} else {
onEnterBlock()
}
}
}
const { onSelect } = useBlockSelection({
id,
blockElement: blockElement.current,
isSelected,
isInputFocused,
...props,
})
const { onKeyDown } = useBlockShortcuts({
id,
onEnterBlock,
...props,
})
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isMacPlatform, isSelected, onEnterBlock, onHideInput])
return (
<div className={classNames('block-wrapper', blockStyles.blockWrapper)} data-block-id={id}>
@ -54,23 +58,19 @@ export const NotebookBlock: React.FunctionComponent<NotebookBlockProps> = ({
or semantic elements that would accurately describe its functionality. To provide the necessary functionality we have
to rely on plain div elements and custom click/focus/keyDown handlers. We still preserve the ability to navigate through blocks
with the keyboard using the up and down arrows, and TAB. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={classNames(
'block',
blockStyles.block,
className,
isSelected && !isInputFocused && blockStyles.selected,
isSelected && isInputFocused && blockStyles.selectedNotFocused
)}
onClick={onSelect}
onDoubleClick={onDoubleClick}
onKeyDown={onKeyDown}
onFocus={onSelect}
// A tabIndex is necessary to make the block focusable.
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={ariaLabel}
ref={blockElement}
>
{children}
</div>

View File

@ -82,27 +82,17 @@ export const NotebookComputeBlock: React.FunctionComponent<ComputeBlockProps> =
platformContext,
isReadOnly,
onRunBlock,
onSelectBlock,
...props
}) => {
const isInputFocused = false
const commonMenuActions = useCommonBlockMenuActions({
isInputFocused,
isReadOnly,
...props,
})
const commonMenuActions = useCommonBlockMenuActions({ id, isReadOnly, ...props })
return (
<NotebookBlock
className={styles.input}
id={id}
isReadOnly={isReadOnly}
isInputFocused={isInputFocused}
aria-label="Notebook compute block"
onEnterBlock={noop}
isSelected={isSelected}
onRunBlock={noop}
onSelectBlock={onSelectBlock}
actions={isSelected ? commonMenuActions : []}
{...props}
>

View File

@ -11,6 +11,8 @@
}
.header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background-color: var(--body-bg);
border-bottom: 1px solid var(--border-color);

View File

@ -8,7 +8,6 @@ import { extensionsController, HIGHLIGHTED_FILE_LINES_LONG } from '@sourcegraph/
import { FileBlockInput } from '../..'
import { WebStory } from '../../../components/WebStory'
import { RepositoryFields } from '../../../graphql-operations'
import { NotebookFileBlock } from './NotebookFileBlock'
@ -33,9 +32,6 @@ const fileBlockInput: FileBlockInput = {
lineRange: null,
}
const resolveRevision = () => of({ commitID: 'commit1', defaultBranch: 'main', rootTreeURL: '' })
const fetchRepository = () => of({ id: 'repo' } as RepositoryFields)
add('default', () => (
<WebStory>
{props => (
@ -49,10 +45,8 @@ add('default', () => (
isReadOnly={false}
isOtherBlockSelected={false}
isSourcegraphDotCom={false}
fetchHighlightedFileLineRanges={() => of(HIGHLIGHTED_FILE_LINES_LONG)}
resolveRevision={resolveRevision}
fetchRepository={fetchRepository}
extensionsController={extensionsController}
sourcegraphSearchLanguageId="sourcegraph"
/>
)}
</WebStory>
@ -71,10 +65,8 @@ add('edit mode', () => (
isReadOnly={false}
isOtherBlockSelected={false}
isSourcegraphDotCom={false}
fetchHighlightedFileLineRanges={() => of(HIGHLIGHTED_FILE_LINES_LONG)}
resolveRevision={resolveRevision}
fetchRepository={fetchRepository}
extensionsController={extensionsController}
sourcegraphSearchLanguageId="sourcegraph"
/>
)}
</WebStory>
@ -93,10 +85,8 @@ add('error fetching file', () => (
isReadOnly={false}
isOtherBlockSelected={false}
isSourcegraphDotCom={false}
fetchHighlightedFileLineRanges={() => of(HIGHLIGHTED_FILE_LINES_LONG)}
resolveRevision={resolveRevision}
fetchRepository={fetchRepository}
extensionsController={extensionsController}
sourcegraphSearchLanguageId="sourcegraph"
/>
)}
</WebStory>

View File

@ -1,12 +1,11 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'
import classNames from 'classnames'
import { debounce } from 'lodash'
import CheckIcon from 'mdi-react/CheckIcon'
import FileDocumentIcon from 'mdi-react/FileDocumentIcon'
import MinusIcon from 'mdi-react/MinusIcon'
import OpenInNewIcon from 'mdi-react/OpenInNewIcon'
import PencilIcon from 'mdi-react/PencilIcon'
import PlusIcon from 'mdi-react/PlusIcon'
import { of } from 'rxjs'
import { startWith } from 'rxjs/operators'
@ -17,44 +16,36 @@ import { HoverMerged } from '@sourcegraph/shared/src/api/client/types/hover'
import { CodeExcerpt } from '@sourcegraph/shared/src/components/CodeExcerpt'
import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller'
import { HoverContext } from '@sourcegraph/shared/src/hover/HoverOverlay'
import { IHighlightLineRange } from '@sourcegraph/shared/src/schema'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { toPrettyBlobURL } from '@sourcegraph/shared/src/util/url'
import { useCodeIntelViewerUpdates } from '@sourcegraph/shared/src/util/useCodeIntelViewerUpdates'
import { LoadingSpinner, useObservable, Link, Alert } from '@sourcegraph/wildcard'
import { BlockProps, FileBlock, FileBlockInput } from '../..'
import { isSingleLineRange, parseFileBlockInput, serializeLineRange } from '../../serialize'
import { parseFileBlockInput, serializeLineRange } from '../../serialize'
import { BlockMenuAction } from '../menu/NotebookBlockMenu'
import { useCommonBlockMenuActions } from '../menu/useCommonBlockMenuActions'
import { NotebookBlock } from '../NotebookBlock'
import { useModifierKeyLabel } from '../useModifierKeyLabel'
import { NotebookFileBlockInputs } from './NotebookFileBlockInputs'
import { FileBlockValidationFunctions, useFileBlockInputValidation } from './useFileBlockInputValidation'
import styles from './NotebookFileBlock.module.scss'
interface NotebookFileBlockProps
extends BlockProps<FileBlock>,
FileBlockValidationFunctions,
TelemetryProps,
ExtensionsControllerProps<'extHostAPI' | 'executeCommand'> {
ExtensionsControllerProps<'extHostAPI' | 'executeCommand'>,
ThemeProps {
sourcegraphSearchLanguageId: string
isSourcegraphDotCom: boolean
hoverifier?: Hoverifier<HoverContext, HoverMerged, ActionItemAction>
}
const LOADING = 'loading' as const
function getFileHeader(input: FileBlockInput): string {
const repositoryName = input.repositoryName.trim()
const filePath = repositoryName ? `/${input.filePath}` : input.filePath
const revision = input.revision ? `@${input.revision}` : ''
const lineRange = serializeLineRange(input.lineRange)
const lines = isSingleLineRange(input.lineRange) ? 'line' : 'lines'
const lineRangeSummary = lineRange ? `, ${lines} ${lineRange}` : ''
return `${repositoryName}${filePath}${revision}${lineRangeSummary}`
}
export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps> = ({
id,
input,
@ -65,53 +56,47 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
isReadOnly,
hoverifier,
extensionsController,
fetchHighlightedFileLineRanges,
resolveRevision,
fetchRepository,
onRunBlock,
onSelectBlock,
onBlockInputChange,
...props
}) => {
const [showInputs, setShowInputs] = useState(input.repositoryName.length === 0 && input.filePath.length === 0)
const [isInputFocused, setIsInputFocused] = useState(false)
const [lineRangeInput, setLineRangeInput] = useState(serializeLineRange(input.lineRange))
const [showRevisionInput, setShowRevisionInput] = useState(input.revision.trim().length > 0)
const [showLineRangeInput, setShowLineRangeInput] = useState(!!input.lineRange)
const [fileQueryInput, setFileQueryInput] = useState('')
const debouncedSetFileQueryInput = useMemo(() => debounce(setFileQueryInput, 300), [setFileQueryInput])
const setFileInput = useCallback(
(newInput: Partial<FileBlockInput>) =>
onBlockInputChange(id, { type: 'file', input: { ...input, ...newInput } }),
[id, input, onBlockInputChange]
const onFileSelected = useCallback(
(input: FileBlockInput) => {
onBlockInputChange(id, { type: 'file', input })
onRunBlock(id)
},
[id, onBlockInputChange, onRunBlock]
)
const { isRepositoryNameValid, isFilePathValid, isRevisionValid, isLineRangeValid } = useFileBlockInputValidation(
input,
lineRangeInput,
{
fetchHighlightedFileLineRanges,
resolveRevision,
fetchRepository,
const onLineRangeChange = useCallback(
(lineRange: IHighlightLineRange | null) => {
onFileSelected({
repositoryName: input.repositoryName,
revision: input.revision,
filePath: input.filePath,
lineRange,
})
},
[input.filePath, input.repositoryName, input.revision, onFileSelected]
)
const onEnterBlock = useCallback(() => {
if (!isReadOnly) {
setShowInputs(true)
}
)
const onEnterBlock = useCallback(() => setShowInputs(true), [setShowInputs])
}, [isReadOnly, setShowInputs])
const hideInputs = useCallback(() => {
setShowInputs(false)
setIsInputFocused(false)
}, [setShowInputs, setIsInputFocused])
}, [setShowInputs])
const isFileSelected = input.repositoryName.length > 0 && input.filePath.length > 0
const blobLines = useObservable(useMemo(() => output?.pipe(startWith(LOADING)) ?? of(undefined), [output]))
const areInputsValid =
isRepositoryNameValid === true &&
isFilePathValid === true &&
isRevisionValid !== false &&
isLineRangeValid !== false
const commonMenuActions = useCommonBlockMenuActions({ isInputFocused, isReadOnly, ...props })
const commonMenuActions = useCommonBlockMenuActions({ id, isReadOnly, ...props })
const fileURL = useMemo(
() =>
toPrettyBlobURL({
@ -127,7 +112,6 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
}),
[input]
)
const linkMenuAction: BlockMenuAction[] = useMemo(
() => [
{
@ -135,12 +119,10 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
label: 'Open in new tab',
icon: <OpenInNewIcon className="icon-inline" />,
url: fileURL,
isDisabled: !areInputsValid,
},
],
[fileURL, areInputsValid]
[fileURL]
)
const modifierKeyLabel = useModifierKeyLabel()
const toggleEditMenuAction: BlockMenuAction[] = useMemo(
() => [
@ -155,55 +137,11 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
[setShowInputs, modifierKeyLabel, showInputs]
)
const toggleOptionalInputsMenuActions: BlockMenuAction[] = useMemo(
() => [
{
type: 'button',
label: showRevisionInput ? 'Remove revision' : 'Add revision',
icon: showRevisionInput ? <MinusIcon className="icon-inline" /> : <PlusIcon className="icon-inline" />,
onClick: () => {
setFileInput({ revision: '' })
setShowRevisionInput(!showRevisionInput)
},
},
{
type: 'button',
label: showLineRangeInput ? 'Remove line range' : 'Add line range',
icon: showLineRangeInput ? <MinusIcon className="icon-inline" /> : <PlusIcon className="icon-inline" />,
onClick: () => {
setLineRangeInput('')
setFileInput({ lineRange: null })
setShowLineRangeInput(!showLineRangeInput)
},
},
],
[setFileInput, showLineRangeInput, showRevisionInput]
)
const menuActions = useMemo(
() =>
(!isReadOnly ? toggleEditMenuAction : [])
.concat(showInputs ? toggleOptionalInputsMenuActions : [])
.concat(linkMenuAction)
.concat(commonMenuActions),
[
isReadOnly,
toggleEditMenuAction,
showInputs,
toggleOptionalInputsMenuActions,
linkMenuAction,
commonMenuActions,
]
() => (!isReadOnly ? toggleEditMenuAction : []).concat(linkMenuAction).concat(commonMenuActions),
[isReadOnly, toggleEditMenuAction, linkMenuAction, commonMenuActions]
)
// Automatically fetch the highlighted file on each input change, if all inputs are valid
useEffect(() => {
if (!showInputs || !areInputsValid) {
return
}
onRunBlock(id)
}, [id, input, showInputs, areInputsValid, onRunBlock])
const onFileURLPaste = useCallback(
(event: ClipboardEvent) => {
if (!isSelected || !showInputs || !event.clipboardData) {
@ -214,14 +152,9 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
if (parsedFileInput.repositoryName.length === 0 || parsedFileInput.filePath.length === 0) {
return
}
setShowRevisionInput(parsedFileInput.revision.length > 0)
setShowLineRangeInput(!!parsedFileInput.lineRange)
if (parsedFileInput.lineRange) {
setLineRangeInput(serializeLineRange(parsedFileInput.lineRange))
}
setFileInput(parsedFileInput)
onFileSelected(parsedFileInput)
},
[showInputs, isSelected, setFileInput, setShowRevisionInput, setShowLineRangeInput, setLineRangeInput]
[isSelected, showInputs, onFileSelected]
)
useEffect(() => {
@ -243,45 +176,29 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
<NotebookBlock
className={styles.block}
id={id}
isReadOnly={isReadOnly}
isInputFocused={isInputFocused}
aria-label="Notebook file block"
onEnterBlock={onEnterBlock}
isSelected={isSelected}
isOtherBlockSelected={isOtherBlockSelected}
onRunBlock={hideInputs}
onBlockInputChange={onBlockInputChange}
onSelectBlock={onSelectBlock}
onHideInput={hideInputs}
actions={isSelected ? menuActions : linkMenuAction}
{...props}
>
{showInputs ? (
<div className={styles.header} data-testid="file-block-header">
{isFileSelected ? <NotebookFileBlockHeader {...input} fileURL={fileURL} /> : <>No file selected.</>}
</div>
{showInputs && (
<NotebookFileBlockInputs
id={id}
{...input}
lineRangeInput={lineRangeInput}
isRepositoryNameValid={isRepositoryNameValid}
isFilePathValid={isFilePathValid}
isRevisionValid={isRevisionValid}
isLineRangeValid={isLineRangeValid}
showRevisionInput={showRevisionInput}
showLineRangeInput={showLineRangeInput}
setIsInputFocused={setIsInputFocused}
onSelectBlock={onSelectBlock}
setFileInput={setFileInput}
setLineRangeInput={setLineRangeInput}
lineRange={input.lineRange}
onLineRangeChange={onLineRangeChange}
queryInput={fileQueryInput}
setQueryInput={setFileQueryInput}
debouncedSetQueryInput={debouncedSetFileQueryInput}
onRunBlock={hideInputs}
onFileSelected={onFileSelected}
{...props}
/>
) : (
<div className={styles.header} data-testid="file-block-header">
<FileDocumentIcon className="icon-inline mr-2" />
{areInputsValid ? (
<Link className={styles.headerFileLink} to={fileURL}>
{getFileHeader(input)}
</Link>
) : (
<span>{getFileHeader(input)}</span>
)}
</div>
)}
{blobLines && blobLines === LOADING && (
<div className={classNames('d-flex justify-content-center py-3', styles.highlightedFileWrapper)}>
@ -313,3 +230,29 @@ export const NotebookFileBlock: React.FunctionComponent<NotebookFileBlockProps>
</NotebookBlock>
)
}
const NotebookFileBlockHeader: React.FunctionComponent<FileBlockInput & { fileURL: string }> = ({
repositoryName,
filePath,
revision,
lineRange,
fileURL,
}) => (
<>
<div className="mr-2">
<FileDocumentIcon className="icon-inline" />
</div>
<div className="d-flex flex-column">
<div className="mb-1 d-flex align-items-center">
<Link className={styles.headerFileLink} to={fileURL}>
{filePath}
{lineRange && <>#{serializeLineRange(lineRange)}</>}
</Link>
</div>
<small className="text-muted">
{repositoryName}
{revision && <>@{revision}</>}
</small>
</div>
</>
)

View File

@ -1,30 +0,0 @@
.suggestions-list {
// Reach Combobox uses bold font to highlight non-matching parts of suggestions.
// We invert that here.
[data-suggested-value] {
font-weight: normal;
}
[data-user-value] {
font-weight: bold;
}
}
.suggestions-option {
display: block;
padding: 0.5rem;
&:hover {
background-color: var(--body-bg) !important;
}
&[aria-selected='true'] {
background-color: var(--body-bg);
}
}
.suggestions-popover {
background-color: var(--input-bg);
max-height: 18rem;
overflow: auto;
}

View File

@ -1,74 +0,0 @@
import React from 'react'
import { storiesOf } from '@storybook/react'
import { noop } from 'lodash'
import { WebStory } from '../../../components/WebStory'
import { NotebookFileBlockInput } from './NotebookFileBlockInput'
const { add } = storiesOf('web/search/notebooks/blocks/file/NotebookFileBlockInput', module).addDecorator(story => (
<div className="container" style={{ padding: '1rem 1rem 8rem 1rem' }}>
{story()}
</div>
))
add('default', () => (
<WebStory>
{() => (
<NotebookFileBlockInput
placeholder="File block input"
value="client/web/file.tsx"
onChange={noop}
onFocus={noop}
onBlur={noop}
/>
)}
</WebStory>
))
add('default with suggestions', () => (
<WebStory>
{() => (
<NotebookFileBlockInput
placeholder="File block input"
value="client/web/file"
onChange={noop}
onFocus={noop}
onBlur={noop}
suggestions={['client/web/file1.tsx', 'client/web/file2.tsx', 'client/web/file3.tsx']}
focusInput={true}
/>
)}
</WebStory>
))
add('valid', () => (
<WebStory>
{() => (
<NotebookFileBlockInput
placeholder="File block input"
value="client/web/file.tsx"
onChange={noop}
onFocus={noop}
onBlur={noop}
isValid={true}
/>
)}
</WebStory>
))
add('invalid', () => (
<WebStory>
{() => (
<NotebookFileBlockInput
placeholder="File block input"
value="client/web/file.tsx"
onChange={noop}
onFocus={noop}
onBlur={noop}
isValid={false}
/>
)}
</WebStory>
))

View File

@ -1,154 +0,0 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxPopover,
ComboboxOptionText,
ComboboxList,
} from '@reach/combobox'
import classNames from 'classnames'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { isModifierKeyPressed } from '../useBlockShortcuts'
import styles from './NotebookFileBlockInput.module.scss'
interface NotebookFileBlockInputProps {
id?: string
className?: string
inputClassName?: string
placeholder: string
value: string
onChange: (value: string) => void
onFocus: (event: React.FocusEvent<HTMLInputElement>) => void
onBlur: (event: React.FocusEvent<HTMLInputElement>) => void
suggestions?: string[]
suggestionsIcon?: JSX.Element
isValid?: boolean
focusInput?: boolean
dataTestId?: string
}
export const NotebookFileBlockInput: React.FunctionComponent<NotebookFileBlockInputProps> = ({
id,
className,
inputClassName,
placeholder,
value,
onChange,
onFocus,
onBlur,
suggestions,
suggestionsIcon,
isValid,
focusInput,
dataTestId,
}) => {
const [inputValue, setInputValue] = useState(value)
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
const onSelect = useCallback(
(value: string) => {
setInputValue(value)
onChange(value)
},
[onChange, setInputValue]
)
const inputReference = useRef<HTMLInputElement>(null)
useEffect(() => {
if (focusInput) {
inputReference.current?.focus()
}
// Only focus input on the initial render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputReference])
useEffect(() => setInputValue(value), [setInputValue, value])
const popoverReference = useRef<HTMLDivElement>(null)
const onKeyDown = (event: React.KeyboardEvent): void => {
// Reach Combobox does not automatically scroll the popover when moving the selected item with a keyboard.
// We have to do it manually by finding the currently selected option and scrolling the popover container
// to make it visible. We are using requestAnimationFrame to prevent triggering reflows because we are
// referencing element sizes and scroll positions.
// Code adapted from: https://github.com/reach/reach-ui/issues/357
window.requestAnimationFrame(() => {
const container = popoverReference.current
if (!container) {
return
}
const element = container.querySelector<HTMLElement>('[aria-selected=true]')
if (!element) {
return
}
const top = element.offsetTop - container.scrollTop
const bottom = container.scrollTop + container.clientHeight - (element.offsetTop + element.clientHeight)
if (bottom < 0) {
container.scrollTop -= bottom
}
if (top < 0) {
container.scrollTop += top
}
})
if (event.key === 'Escape') {
const target = event.target as HTMLElement
target.blur()
} else if (event.key === 'Tab' && !event.shiftKey) {
// Reach does not support 'Tab' as a select trigger, so we have to manually select the currently highlighted suggestion.
const element = popoverReference.current?.querySelector<HTMLElement>(
'[aria-selected=true] [data-suggestion-value]'
)
if (element?.dataset.suggestionValue) {
event.preventDefault()
onSelect(element.dataset.suggestionValue)
}
} else if (
// Allow cmd+Enter/ctrl+Enter to propagate to run the block, stop all other events
!(event.key === 'Enter' && isModifierKeyPressed(event.metaKey, event.ctrlKey, isMacPlatform))
) {
event.stopPropagation()
}
}
return (
<Combobox openOnFocus={true} onSelect={onSelect} className={className} onKeyDown={onKeyDown}>
<ComboboxInput
id={id}
ref={inputReference}
className={classNames(
inputClassName,
'form-control',
isValid === true && 'is-valid',
isValid === false && 'is-invalid'
)}
value={inputValue}
placeholder={placeholder}
onChange={event => onSelect(event.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onPaste={event => event.stopPropagation()}
data-testid={dataTestId}
/>
{/* Only show suggestions popover for the latest input value and if it does not contain an exact match.
This is to prevent opening the suggestions popover when a file URL is pasted into the file block. */}
{suggestions && value === inputValue && !suggestions.includes(inputValue) && (
<ComboboxPopover ref={popoverReference} className={styles.suggestionsPopover}>
<ComboboxList className={styles.suggestionsList}>
{suggestions.map(suggestion => (
<ComboboxOption className={styles.suggestionsOption} key={suggestion} value={suggestion}>
<span data-suggestion-value={suggestion}>
{suggestionsIcon}
<ComboboxOptionText />
</span>
</ComboboxOption>
))}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
)
}

View File

@ -1,8 +1,6 @@
.file-block-inputs {
padding: 1rem;
background-color: var(--body-bg);
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
border-bottom: 1px solid var(--border-color);
}
@ -31,3 +29,27 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}
.file-suggestions {
display: flex;
flex-direction: column;
overflow: auto;
max-height: 15rem;
padding-top: 0.5rem;
padding-right: 0.5rem;
border-top: 1px solid var(--border-color);
}
.file-button {
display: flex;
flex-direction: column;
width: 100%;
font-weight: normal;
text-align: left;
margin-bottom: 0.25rem;
border: 1px solid var(--color-bg-2);
&:hover {
background-color: var(--color-bg-2);
}
}

View File

@ -13,48 +13,18 @@ const { add } = storiesOf('web/search/notebooks/blocks/file/NotebookFileBlockInp
const defaultProps = {
id: 'block-id',
showRevisionInput: true,
showLineRangeInput: true,
setIsInputFocused: noop,
setFileInput: noop,
setLineRangeInput: noop,
onSelectBlock: noop,
isRepositoryNameValid: undefined,
isFilePathValid: undefined,
isRevisionValid: undefined,
isLineRangeValid: undefined,
repositoryName: 'github.com/sourcegraph/sourcegraph',
revision: 'main',
filePath: 'client/web/file.tsx',
lineRangeInput: '123-321',
sourcegraphSearchLanguageId: 'sourcegraph',
queryInput: '',
setQueryInput: noop,
debouncedSetQueryInput: noop,
onFileSelected: noop,
onRunBlock: noop,
lineRange: null,
onLineRangeChange: noop,
}
add('default', () => <WebStory>{() => <NotebookFileBlockInputs {...defaultProps} />}</WebStory>)
add('all valid', () => (
<WebStory>
{() => (
<NotebookFileBlockInputs
{...defaultProps}
isRepositoryNameValid={true}
isFilePathValid={true}
isRevisionValid={true}
isLineRangeValid={true}
/>
)}
</WebStory>
))
add('all invalid', () => (
<WebStory>
{() => (
<NotebookFileBlockInputs
{...defaultProps}
isRepositoryNameValid={false}
isFilePathValid={false}
isRevisionValid={false}
isLineRangeValid={false}
/>
)}
</WebStory>
))
add('default', () => <WebStory>{webProps => <NotebookFileBlockInputs {...webProps} {...defaultProps} />}</WebStory>)

View File

@ -1,108 +1,87 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState, useCallback } from 'react'
import classNames from 'classnames'
import { debounce } from 'lodash'
import FileDocumentIcon from 'mdi-react/FileDocumentIcon'
import InfoCircleOutlineIcon from 'mdi-react/InfoCircleOutlineIcon'
import SourceRepositoryIcon from 'mdi-react/SourceRepositoryIcon'
import * as Monaco from 'monaco-editor'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { PathMatch, RepositoryMatch } from '@sourcegraph/shared/src/search/stream'
import { useObservable } from '@sourcegraph/wildcard'
import { IHighlightLineRange } from '@sourcegraph/shared/src/schema'
import { PathMatch } from '@sourcegraph/shared/src/search/stream'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { Button } from '@sourcegraph/wildcard'
import { BlockProps, FileBlockInput } from '../..'
import { parseLineRange } from '../../serialize'
import { fetchSuggestions } from '../suggestions'
import { NotebookFileBlockInput } from './NotebookFileBlockInput'
import { FileBlockInputValidationResult } from './useFileBlockInputValidation'
import { parseLineRange, serializeLineRange } from '../../serialize'
import { SearchTypeSuggestionsInput } from '../suggestions/SearchTypeSuggestionsInput'
import { fetchSuggestions } from '../suggestions/suggestions'
import styles from './NotebookFileBlockInputs.module.scss'
interface NotebookFileBlockInputsProps
extends FileBlockInputValidationResult,
Omit<FileBlockInput, 'lineRange'>,
Pick<BlockProps, 'onSelectBlock'> {
interface NotebookFileBlockInputsProps extends Pick<BlockProps, 'onRunBlock'>, ThemeProps {
id: string
lineRangeInput: string
showRevisionInput: boolean
showLineRangeInput: boolean
setIsInputFocused(value: boolean): void
setFileInput: (input: Partial<FileBlockInput>) => void
setLineRangeInput: (input: string) => void
sourcegraphSearchLanguageId: string
queryInput: string
lineRange: IHighlightLineRange | null
setQueryInput: (value: string) => void
debouncedSetQueryInput: (value: string) => void
onLineRangeChange: (lineRange: IHighlightLineRange | null) => void
onFileSelected: (file: FileBlockInput) => void
}
const MAX_SUGGESTIONS = 15
function getRepositorySuggestionsQuery(repositoryName: string): string {
return `repo:${repositoryName} type:repo count:${MAX_SUGGESTIONS} fork:yes`
}
function getFilePathSuggestionsQuery(repositoryName: string, revision: string, filePath: string): string {
const repoFilter = repositoryName.trim() ? `repo:${repositoryName}` : ''
const revisionFilter = revision.trim() ? `rev:${revision}` : ''
return `${repoFilter} ${revisionFilter} ${filePath} type:path count:${MAX_SUGGESTIONS} fork:yes`
function getFileSuggestionsQuery(queryInput: string): string {
return `${queryInput} fork:yes type:path count:50`
}
export const NotebookFileBlockInputs: React.FunctionComponent<NotebookFileBlockInputsProps> = ({
id,
repositoryName,
filePath,
revision,
lineRangeInput,
isRepositoryNameValid,
isFilePathValid,
isRevisionValid,
isLineRangeValid,
showRevisionInput,
showLineRangeInput,
setIsInputFocused,
onSelectBlock,
setFileInput,
setLineRangeInput,
lineRange,
onFileSelected,
onLineRangeChange,
...props
}) => {
const onInputFocus = (event: React.FocusEvent<HTMLInputElement>): void => {
onSelectBlock(id)
setIsInputFocused(true)
event.preventDefault()
event.stopPropagation()
}
const [editor, setEditor] = useState<Monaco.editor.IStandaloneCodeEditor>()
const [lineRangeInput, setLineRangeInput] = useState(serializeLineRange(lineRange))
const debouncedOnLineRangeChange = useMemo(() => debounce(onLineRangeChange, 300), [onLineRangeChange])
const debouncedSetFileInput = useMemo(() => debounce(setFileInput, 300), [setFileInput])
const onInputBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
// relatedTarget contains the element that will receive focus after the blur.
const relatedTarget = event.relatedTarget && (event.relatedTarget as HTMLElement)
// If relatedTarget is another input from the same block or contained within the suggestions popover list, we
// want to keep the input focused. Otherwise this will result in quickly flashing focus between elements.
if (relatedTarget?.tagName.toLowerCase() !== 'input' && !relatedTarget?.closest('[data-reach-combobox-list]')) {
setIsInputFocused(false)
}
event.stopPropagation()
}
const repoSuggestions = useObservable(
useMemo(
() =>
fetchSuggestions(
getRepositorySuggestionsQuery(repositoryName),
(suggestion): suggestion is RepositoryMatch => suggestion.type === 'repo',
repo => repo.repository
),
[repositoryName]
)
const isLineRangeValid = useMemo(
() => (lineRangeInput.trim() ? parseLineRange(lineRangeInput) !== null : undefined),
[lineRangeInput]
)
const fileSuggestions = useObservable(
useMemo(
() =>
fetchSuggestions(
getFilePathSuggestionsQuery(repositoryName, revision, filePath),
(suggestion): suggestion is PathMatch => suggestion.type === 'path',
repo => repo.path
),
[repositoryName, revision, filePath]
)
const onLineRangeInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setLineRangeInput(event.target.value)
debouncedOnLineRangeChange(parseLineRange(event.target.value))
},
[setLineRangeInput, debouncedOnLineRangeChange]
)
const fetchFileSuggestions = useCallback(
(query: string) =>
fetchSuggestions(
getFileSuggestionsQuery(query),
(suggestion): suggestion is PathMatch => suggestion.type === 'path',
file => file
),
[]
)
const countSuggestions = useCallback((suggestions: PathMatch[]) => suggestions.length, [])
const onFileSuggestionSelected = useCallback(
(file: FileBlockInput) => {
onFileSelected(file)
setLineRangeInput(serializeLineRange(file.lineRange))
},
[onFileSelected, setLineRangeInput]
)
const renderSuggestions = useCallback(
(suggestions: PathMatch[]) => (
<FileSuggestions suggestions={suggestions} onFileSelected={onFileSuggestionSelected} />
),
[onFileSuggestionSelected]
)
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
@ -111,80 +90,64 @@ export const NotebookFileBlockInputs: React.FunctionComponent<NotebookFileBlockI
<div className={styles.fileBlockInputs}>
<div className="text-muted mb-2">
<small>
<InfoCircleOutlineIcon className="icon-inline" /> To automatically fill the inputs, copy a
Sourcegraph file URL, select the block, and paste the URL ({isMacPlatform ? '⌘' : 'Ctrl'} + v).
<InfoCircleOutlineIcon className="icon-inline" /> To automatically select a file, copy a Sourcegraph
file URL, select the block, and paste the URL ({isMacPlatform ? '⌘' : 'Ctrl'} + v).
</small>
</div>
<label htmlFor={`file-location-input-${id}`}>File location</label>
<div id={`file-location-input-${id}`} className={styles.fileLocationInputWrapper}>
<NotebookFileBlockInput
className="flex-1"
inputClassName={styles.repositoryNameInput}
value={repositoryName}
placeholder="Repository name"
onChange={repositoryName => debouncedSetFileInput({ repositoryName })}
onFocus={onInputFocus}
onBlur={onInputBlur}
suggestions={repoSuggestions}
suggestionsIcon={<SourceRepositoryIcon className="mr-1" size="1rem" />}
isValid={isRepositoryNameValid}
dataTestId="file-block-repository-name-input"
<SearchTypeSuggestionsInput<PathMatch>
id={id}
editor={editor}
setEditor={setEditor}
label="Find a file using a Sourcegraph search query"
queryPrefix="type:path"
fetchSuggestions={fetchFileSuggestions}
countSuggestions={countSuggestions}
renderSuggestions={renderSuggestions}
{...props}
/>
<div className="mt-2">
<label htmlFor={`${id}-line-range-input`}>Line range</label>
<input
id={`${id}-line-range-input`}
type="text"
className={classNames('form-control', isLineRangeValid === false && 'is-invalid')}
value={lineRangeInput}
onChange={onLineRangeInputChange}
placeholder="Enter a single line (1), a line range (1-10), or leave empty to show the entire file."
/>
<div className={styles.separator} />
<NotebookFileBlockInput
className="flex-1"
inputClassName={styles.filePathInput}
value={filePath}
placeholder="Path"
onChange={filePath => debouncedSetFileInput({ filePath })}
onFocus={onInputFocus}
onBlur={onInputBlur}
suggestions={fileSuggestions}
suggestionsIcon={<FileDocumentIcon className="mr-1" size="1rem" />}
isValid={isFilePathValid}
dataTestId="file-block-file-path-input"
/>
</div>
<div className={classNames('d-flex', (showRevisionInput || showLineRangeInput) && 'mt-3')}>
{showRevisionInput && (
<div className="w-50 mr-2">
<label htmlFor={`file-revision-input-${id}`}>Revision</label>
<NotebookFileBlockInput
id={`file-revision-input-${id}`}
inputClassName={styles.revisionInput}
value={revision}
placeholder="feature/branch"
onChange={revision => debouncedSetFileInput({ revision })}
onFocus={onInputFocus}
onBlur={onInputBlur}
isValid={isRevisionValid}
dataTestId="file-block-revision-input"
/>
</div>
)}
{showLineRangeInput && (
<div className="w-50">
<label htmlFor={`file-line-range-input-${id}`}>Line range</label>
<NotebookFileBlockInput
id={`file-line-range-input-${id}`}
inputClassName={styles.lineRangeInput}
value={lineRangeInput}
placeholder="1-10"
onChange={lineRangeInput => {
setLineRangeInput(lineRangeInput)
const lineRange = parseLineRange(lineRangeInput)
if (lineRange !== null) {
debouncedSetFileInput({ lineRange })
}
}}
onFocus={onInputFocus}
onBlur={onInputBlur}
isValid={isLineRangeValid}
dataTestId="file-block-line-range-input"
/>
{isLineRangeValid === false && (
<div className="text-danger mt-1">
Line range is invalid. Enter a single line (1), a line range (1-10), or leave empty to show the
entire file.
</div>
)}
</div>
</div>
)
}
const FileSuggestions: React.FunctionComponent<{
suggestions: PathMatch[]
onFileSelected: (symbol: FileBlockInput) => void
}> = ({ suggestions, onFileSelected }) => (
<div className={styles.fileSuggestions}>
{suggestions.map(suggestion => (
<Button
className={styles.fileButton}
key={`${suggestion.repository}_${suggestion.path}`}
onClick={() =>
onFileSelected({
repositoryName: suggestion.repository,
filePath: suggestion.path,
revision: suggestion.commit ?? '',
lineRange: null,
})
}
data-testid="file-suggestion-button"
>
<span className="mb-1">{suggestion.path}</span>
<small className="text-muted">{suggestion.repository}</small>
</Button>
))}
</div>
)

View File

@ -1,87 +0,0 @@
import { useMemo } from 'react'
import { Observable, of } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt'
import { useObservable } from '@sourcegraph/wildcard'
import { FileBlockInput } from '../..'
import { fetchRepository as _fetchRepository, resolveRevision as _resolveRevision } from '../../../repo/backend'
import { parseLineRange } from '../../serialize'
function validateInput<T>(
input: string,
validationFn: (input: string) => Observable<T>
): Observable<boolean | undefined> {
return input.trim()
? validationFn(input).pipe(
map(() => true),
catchError(() => of(false))
)
: of(undefined)
}
export interface FileBlockValidationFunctions {
fetchRepository: typeof _fetchRepository
resolveRevision: typeof _resolveRevision
fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable<string[][]>
}
export interface FileBlockInputValidationResult {
isRepositoryNameValid: boolean | undefined
isFilePathValid: boolean | undefined
isRevisionValid: boolean | undefined
isLineRangeValid: boolean | undefined
}
export function useFileBlockInputValidation(
input: Omit<FileBlockInput, 'lineRange'>,
lineRangeInput: string,
{ fetchRepository, resolveRevision, fetchHighlightedFileLineRanges }: FileBlockValidationFunctions
): FileBlockInputValidationResult {
const isRepositoryNameValid = useObservable(
useMemo(() => validateInput(input.repositoryName, repoName => fetchRepository({ repoName })), [
fetchRepository,
input.repositoryName,
])
)
const isFilePathValid = useObservable(
useMemo(
() =>
validateInput(input.filePath, filePath =>
fetchHighlightedFileLineRanges({
repoName: input.repositoryName,
commitID: input.revision,
filePath,
ranges: [],
disableTimeout: true,
})
),
[input.repositoryName, input.filePath, input.revision, fetchHighlightedFileLineRanges]
)
)
const isRevisionValid = useObservable(
useMemo(
() =>
validateInput(input.revision, revision =>
resolveRevision({ repoName: input.repositoryName, revision })
),
[input.repositoryName, input.revision, resolveRevision]
)
)
const isLineRangeValid = useMemo(
() => (lineRangeInput.trim() ? parseLineRange(lineRangeInput) !== null : undefined),
[lineRangeInput]
)
return {
isRepositoryNameValid,
isFilePathValid,
isRevisionValid,
isLineRangeValid,
}
}

View File

@ -14,6 +14,7 @@ import { BlockProps, MarkdownBlock } from '../..'
import { BlockMenuAction } from '../menu/NotebookBlockMenu'
import { useCommonBlockMenuActions } from '../menu/useCommonBlockMenuActions'
import { NotebookBlock } from '../NotebookBlock'
import { useIsBlockInputFocused } from '../useIsBlockInputFocused'
import { useModifierKeyLabel } from '../useModifierKeyLabel'
import { MONACO_BLOCK_INPUT_OPTIONS, useMonacoBlockInput } from '../useMonacoBlockInput'
@ -31,7 +32,6 @@ export const NotebookMarkdownBlock: React.FunctionComponent<NotebookMarkdownBloc
isReadOnly,
onBlockInputChange,
onRunBlock,
onSelectBlock,
...props
}) => {
const [isEditing, setIsEditing] = useState(!isReadOnly && input.length === 0)
@ -50,12 +50,12 @@ export const NotebookMarkdownBlock: React.FunctionComponent<NotebookMarkdownBloc
onBlockInputChange,
])
const { isInputFocused } = useMonacoBlockInput({
useMonacoBlockInput({
editor,
id,
tabMovesFocus: false,
...props,
onInputChange,
onSelectBlock,
onRunBlock: runBlock,
})
@ -65,9 +65,8 @@ export const NotebookMarkdownBlock: React.FunctionComponent<NotebookMarkdownBloc
}
if (!isEditing) {
setIsEditing(true)
onSelectBlock(id)
}
}, [id, isReadOnly, isEditing, setIsEditing, onSelectBlock])
}, [isReadOnly, isEditing, setIsEditing])
// setTimeout turns on editing mode in a separate run-loop which prevents adding a newline at the start of the input
const onEnterBlock = useCallback(() => {
@ -83,11 +82,7 @@ export const NotebookMarkdownBlock: React.FunctionComponent<NotebookMarkdownBloc
}
}, [isEditing, editor])
const commonMenuActions = useCommonBlockMenuActions({
isInputFocused,
isReadOnly,
...props,
})
const commonMenuActions = useCommonBlockMenuActions({ id, isReadOnly, ...props })
const modifierKeyLabel = useModifierKeyLabel()
const menuActions = useMemo(() => {
@ -114,31 +109,20 @@ export const NotebookMarkdownBlock: React.FunctionComponent<NotebookMarkdownBloc
const notebookBlockProps = useMemo(
() => ({
id,
isInputFocused,
onEnterBlock,
isReadOnly,
isSelected,
onRunBlock,
onBlockInputChange,
onSelectBlock,
actions: isSelected ? menuActions : [],
actions: isSelected && !isReadOnly ? menuActions : [],
'aria-label': 'Notebook markdown block',
...props,
}),
[
id,
isInputFocused,
isReadOnly,
isSelected,
menuActions,
onBlockInputChange,
onEnterBlock,
onRunBlock,
onSelectBlock,
props,
]
[id, isReadOnly, isSelected, menuActions, onBlockInputChange, onEnterBlock, onRunBlock, props]
)
const isInputFocused = useIsBlockInputFocused(id)
if (!isEditing) {
return (
<NotebookBlock {...notebookBlockProps} onDoubleClick={onDoubleClick}>

View File

@ -8,24 +8,21 @@ import DeleteIcon from 'mdi-react/DeleteIcon'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { BlockProps } from '../..'
import { useIsBlockInputFocused } from '../useIsBlockInputFocused'
import { useModifierKeyLabel } from '../useModifierKeyLabel'
import { BlockMenuAction } from './NotebookBlockMenu'
interface UseCommonBlockMenuActionsOptions
extends Pick<BlockProps, 'isReadOnly' | 'onDeleteBlock' | 'onDuplicateBlock' | 'onMoveBlock'> {
isInputFocused: boolean
}
export const useCommonBlockMenuActions = ({
isInputFocused,
id,
isReadOnly,
onMoveBlock,
onDeleteBlock,
onDuplicateBlock,
}: UseCommonBlockMenuActionsOptions): BlockMenuAction[] => {
}: Pick<BlockProps, 'id' | 'isReadOnly' | 'onDeleteBlock' | 'onDuplicateBlock' | 'onMoveBlock'>): BlockMenuAction[] => {
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
const modifierKeyLabel = useModifierKeyLabel()
const isInputFocused = useIsBlockInputFocused(id)
return useMemo(() => {
if (isReadOnly) {
return []

View File

@ -15,4 +15,10 @@
padding: 0.5rem;
background-color: var(--color-bg-1);
border: 1px solid var(--border-color);
&:focus,
&:focus-within {
border: 1px solid var(--border-active-color) !important;
box-shadow: 0 0 0 0.125rem var(--primary-2);
}
}

View File

@ -68,7 +68,6 @@ export const NotebookQueryBlock: React.FunctionComponent<NotebookQueryBlockProps
onBlockInputChange,
fetchHighlightedFileLineRanges,
onRunBlock,
onSelectBlock,
...props
}) => {
const showSearchContext = useExperimentalFeatures(features => features.showSearchContext ?? false)
@ -76,26 +75,15 @@ export const NotebookQueryBlock: React.FunctionComponent<NotebookQueryBlockProps
const searchResults = useObservable(output ?? of(undefined))
const location = useLocation()
const runBlock = useCallback(
(id: string) => {
if (!isSelected) {
onSelectBlock(id)
}
onRunBlock(id)
},
[isSelected, onRunBlock, onSelectBlock]
)
const onInputChange = useCallback((input: string) => onBlockInputChange(id, { type: 'query', input }), [
id,
onBlockInputChange,
])
const { isInputFocused } = useMonacoBlockInput({
useMonacoBlockInput({
editor,
id,
onRunBlock: runBlock,
onSelectBlock,
onRunBlock,
onInputChange,
...props,
})
@ -113,10 +101,10 @@ export const NotebookQueryBlock: React.FunctionComponent<NotebookQueryBlockProps
label: isLoading ? 'Searching...' : 'Run search',
isDisabled: isLoading ?? false,
icon: <PlayCircleOutlineIcon className="icon-inline" />,
onClick: runBlock,
onClick: onRunBlock,
keyboardShortcutLabel: isSelected ? `${modifierKeyLabel} + ↵` : '',
}
}, [runBlock, isSelected, modifierKeyLabel, searchResults])
}, [onRunBlock, isSelected, modifierKeyLabel, searchResults])
const linkMenuActions: BlockMenuAction[] = useMemo(
() => [
@ -130,7 +118,7 @@ export const NotebookQueryBlock: React.FunctionComponent<NotebookQueryBlockProps
[input]
)
const commonMenuActions = linkMenuActions.concat(useCommonBlockMenuActions({ isInputFocused, ...props }))
const commonMenuActions = linkMenuActions.concat(useCommonBlockMenuActions({ id, ...props }))
useQueryDiagnostics(editor, { patternType: SearchPatternType.literal, interpretComments: true })
@ -147,26 +135,16 @@ export const NotebookQueryBlock: React.FunctionComponent<NotebookQueryBlockProps
<NotebookBlock
className={styles.block}
id={id}
isInputFocused={isInputFocused}
aria-label="Notebook query block"
onEnterBlock={onEnterBlock}
isSelected={isSelected}
isOtherBlockSelected={isOtherBlockSelected}
onRunBlock={onRunBlock}
onBlockInputChange={onBlockInputChange}
onSelectBlock={onSelectBlock}
mainAction={mainMenuAction}
actions={isSelected ? commonMenuActions : linkMenuActions}
{...props}
>
<div className="mb-1 text-muted">Search query</div>
<div
className={classNames(
blockStyles.monacoWrapper,
isInputFocused && blockStyles.selected,
styles.queryInputMonacoWrapper
)}
>
<div className={classNames(blockStyles.monacoWrapper, styles.queryInputMonacoWrapper)}>
<MonacoEditor
language={sourcegraphSearchLanguageId}
value={input}

View File

@ -0,0 +1,22 @@
.search-type-query-part {
border-right: 1px solid var(--border-color-2);
padding-right: 0.5rem;
margin-right: 0.5rem;
font-size: 0.75rem;
line-height: 1rem;
}
.query-input-monaco-wrapper {
display: flex;
align-items: center;
border-radius: 0.25rem;
padding: 0.5rem;
background-color: var(--color-bg-1);
border: 1px solid var(--border-color);
&:focus,
&:focus-within {
border: 1px solid var(--border-active-color) !important;
box-shadow: 0 0 0 0.125rem var(--primary-2);
}
}

View File

@ -0,0 +1,169 @@
import React, { ReactElement, useCallback, useMemo } from 'react'
import classNames from 'classnames'
import { noop } from 'lodash'
import * as Monaco from 'monaco-editor'
import { Observable, of } from 'rxjs'
import { delay, startWith } from 'rxjs/operators'
import { pluralize } from '@sourcegraph/common'
import { createQueryExampleFromString, updateQueryWithFilterAndExample } from '@sourcegraph/search'
import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui'
import { toMonacoSelection } from '@sourcegraph/search-ui/src/input/MonacoQueryInput'
import { MonacoEditor } from '@sourcegraph/shared/src/components/MonacoEditor'
import { FilterType } from '@sourcegraph/shared/src/search/query/filters'
import { toMonacoRange } from '@sourcegraph/shared/src/search/query/monaco'
import { PathMatch, SymbolMatch } from '@sourcegraph/shared/src/search/stream'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { Button, useObservable } from '@sourcegraph/wildcard'
import { BlockProps } from '../..'
import { MONACO_BLOCK_INPUT_OPTIONS, useMonacoBlockInput } from '../useMonacoBlockInput'
import blockStyles from '../NotebookBlock.module.scss'
import styles from './SearchTypeSuggestionsInput.module.scss'
interface SearchTypeSuggestionsInputProps<S extends SymbolMatch | PathMatch>
extends ThemeProps,
Pick<BlockProps, 'onRunBlock'> {
id: string
label: string
sourcegraphSearchLanguageId: string
editor: Monaco.editor.IStandaloneCodeEditor | undefined
queryPrefix: string
queryInput: string
setEditor: (editor: Monaco.editor.IStandaloneCodeEditor) => void
setQueryInput: (value: string) => void
debouncedSetQueryInput: (value: string) => void
fetchSuggestions: (query: string) => Observable<S[]>
countSuggestions: (suggestions: S[]) => number
renderSuggestions: (suggestions: S[]) => ReactElement
}
const LOADING = 'LOADING' as const
const QUERY_EXAMPLE = createQueryExampleFromString('{enter-regexp-pattern}')
export const SearchTypeSuggestionsInput = <S extends SymbolMatch | PathMatch>({
id,
label,
editor,
sourcegraphSearchLanguageId,
queryPrefix,
queryInput,
isLightTheme,
setEditor,
setQueryInput,
debouncedSetQueryInput,
fetchSuggestions,
countSuggestions,
renderSuggestions,
...props
}: SearchTypeSuggestionsInputProps<S>): ReactElement => {
useMonacoBlockInput({
editor,
id,
onInputChange: debouncedSetQueryInput,
preventNewLine: true,
...props,
})
const addExampleFilter = useCallback(
(filterType: FilterType) => {
const { query, placeholderRange, filterRange } = updateQueryWithFilterAndExample(
queryInput,
filterType,
QUERY_EXAMPLE,
{ singular: false, negate: false, emptyValue: false }
)
setQueryInput(query)
const textModel = editor?.getModel()
if (!editor || !textModel) {
return
}
// Focus the selection in the next run-loop, since we have to wait for the Monaco editor to update.
setTimeout(() => {
const selectionRange = toMonacoSelection(toMonacoRange(placeholderRange, textModel))
editor.setSelection(selectionRange)
editor.revealRange(toMonacoRange(filterRange, textModel))
editor.focus()
}, 0)
},
[editor, queryInput, setQueryInput]
)
const suggestions = useObservable(
useMemo(
() =>
queryInput.length > 0
? fetchSuggestions(queryInput).pipe(
// A small delay to prevent flickering loading message.
delay(300),
startWith(LOADING)
)
: of(undefined),
[queryInput, fetchSuggestions]
)
)
const suggestionsCount = useMemo(() => {
if (!suggestions || suggestions === LOADING) {
return undefined
}
return countSuggestions(suggestions)
}, [suggestions, countSuggestions])
return (
<div>
<label htmlFor={`${id}-search-type-query-input`}>{label}</label>
<div
id={`${id}-search-type-query-input`}
className={classNames(blockStyles.monacoWrapper, styles.queryInputMonacoWrapper)}
>
<div className="d-flex">
<SyntaxHighlightedSearchQuery className={styles.searchTypeQueryPart} query={queryPrefix} />
</div>
<div className="flex-1">
<MonacoEditor
language={sourcegraphSearchLanguageId}
value={queryInput}
height={17}
isLightTheme={isLightTheme}
editorWillMount={noop}
onEditorCreated={setEditor}
options={{
...MONACO_BLOCK_INPUT_OPTIONS,
wordWrap: 'off',
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
}}
border={false}
/>
</div>
</div>
<div className="mt-1">
<Button
className="mr-1"
variant="secondary"
size="sm"
onClick={() => addExampleFilter(FilterType.repo)}
>
Filter by repository
</Button>
<Button variant="secondary" size="sm" onClick={() => addExampleFilter(FilterType.file)}>
Filter by file path
</Button>
</div>
<div className="mt-3 mb-1">
{suggestionsCount !== undefined && (
<strong>
{suggestionsCount} {pluralize('result', suggestionsCount)} found
</strong>
)}
{suggestions === LOADING && <strong>Searching...</strong>}
</div>
{suggestions && suggestions !== LOADING && renderSuggestions(suggestions)}
</div>
)
}

View File

@ -65,16 +65,14 @@ export const NotebookSymbolBlock: React.FunctionComponent<NotebookSymbolBlockPro
extensionsController,
isLightTheme,
onRunBlock,
onSelectBlock,
onBlockInputChange,
...props
}) => {
const [editor, setEditor] = useState<Monaco.editor.IStandaloneCodeEditor>()
const [showInputs, setShowInputs] = useState(input.symbolName.length === 0)
const [symbolQueryInput, setSymbolQueryInput] = useState('')
const [isInputFocused, setIsInputFocused] = useState(false)
const debouncedSetSymbolQueryInput = useMemo(() => debounce(setSymbolQueryInput, 300), [setSymbolQueryInput])
const onSymbolSelected = useCallback(
(input: SymbolBlockInput) => {
onBlockInputChange(id, { type: 'symbol', input })
@ -97,7 +95,7 @@ export const NotebookSymbolBlock: React.FunctionComponent<NotebookSymbolBlockPro
const symbolOutput = useObservable(useMemo(() => output?.pipe(startWith(LOADING)) ?? of(undefined), [output]))
const commonMenuActions = useCommonBlockMenuActions({
isInputFocused,
id,
isReadOnly,
...props,
})
@ -166,15 +164,11 @@ export const NotebookSymbolBlock: React.FunctionComponent<NotebookSymbolBlockPro
<NotebookBlock
className={styles.block}
id={id}
isReadOnly={isReadOnly}
isInputFocused={isInputFocused}
aria-label="Notebook symbol block"
onEnterBlock={onEnterBlock}
onHideInput={hideInputs}
isSelected={isSelected}
isOtherBlockSelected={isOtherBlockSelected}
onRunBlock={hideInputs}
onBlockInputChange={onBlockInputChange}
onSelectBlock={onSelectBlock}
actions={isSelected ? menuActions : linkMenuAction}
{...props}
>
@ -198,15 +192,13 @@ export const NotebookSymbolBlock: React.FunctionComponent<NotebookSymbolBlockPro
<NotebookSymbolBlockInput
id={id}
editor={editor}
symbolQueryInput={symbolQueryInput}
queryInput={symbolQueryInput}
isLightTheme={isLightTheme}
setEditor={setEditor}
setSymbolQueryInput={setSymbolQueryInput}
debouncedSetSymbolQueryInput={debouncedSetSymbolQueryInput}
setQueryInput={setSymbolQueryInput}
debouncedSetQueryInput={debouncedSetSymbolQueryInput}
onSymbolSelected={onSymbolSelected}
setIsInputFocused={setIsInputFocused}
onRunBlock={hideInputs}
onSelectBlock={onSelectBlock}
{...props}
/>
)}

View File

@ -1,23 +1,7 @@
.input {
padding: 1rem;
background-color: var(--body-bg);
}
.type-symbol-query-part {
border-right: 1px solid var(--border-color-2);
padding-right: 0.5rem;
margin-right: 0.5rem;
font-size: 0.75rem;
line-height: 1rem;
}
.query-input-monaco-wrapper {
display: flex;
align-items: center;
border-radius: 0.25rem;
padding: 0.5rem;
background-color: var(--color-bg-1);
border: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.symbol-suggestions {
@ -25,6 +9,7 @@
flex-direction: column;
overflow: auto;
max-height: 15rem;
padding-right: 0.5rem;
border-top: 1px solid var(--border-color);
}

View File

@ -1,191 +1,78 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import React, { useCallback, useEffect } from 'react'
import classNames from 'classnames'
import { noop } from 'lodash'
import * as Monaco from 'monaco-editor'
import { of } from 'rxjs'
import { delay, startWith } from 'rxjs/operators'
import { pluralize } from '@sourcegraph/common'
import { createQueryExampleFromString, updateQueryWithFilterAndExample } from '@sourcegraph/search'
import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui'
import { toMonacoSelection } from '@sourcegraph/search-ui/src/input/MonacoQueryInput'
import { MonacoEditor } from '@sourcegraph/shared/src/components/MonacoEditor'
import { RepoFileLink } from '@sourcegraph/shared/src/components/RepoFileLink'
import { FilterType } from '@sourcegraph/shared/src/search/query/filters'
import { toMonacoRange } from '@sourcegraph/shared/src/search/query/monaco'
import { getFileMatchUrl, getRepositoryUrl, SymbolMatch } from '@sourcegraph/shared/src/search/stream'
import { SymbolIcon } from '@sourcegraph/shared/src/symbols/SymbolIcon'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { Button, useObservable } from '@sourcegraph/wildcard'
import { Button } from '@sourcegraph/wildcard'
import { BlockProps, SymbolBlockInput } from '../..'
import { fetchSuggestions } from '../suggestions'
import { MONACO_BLOCK_INPUT_OPTIONS, useMonacoBlockInput } from '../useMonacoBlockInput'
import { SearchTypeSuggestionsInput } from '../suggestions/SearchTypeSuggestionsInput'
import { fetchSuggestions } from '../suggestions/suggestions'
import blockStyles from '../NotebookBlock.module.scss'
import styles from './NotebookSymbolBlockInput.module.scss'
interface NotebookSymbolBlockInputProps extends ThemeProps, Pick<BlockProps, 'onRunBlock' | 'onSelectBlock'> {
interface NotebookSymbolBlockInputProps extends ThemeProps, Pick<BlockProps, 'onRunBlock'> {
id: string
sourcegraphSearchLanguageId: string
editor: Monaco.editor.IStandaloneCodeEditor | undefined
symbolQueryInput: string
queryInput: string
setEditor: (editor: Monaco.editor.IStandaloneCodeEditor) => void
setSymbolQueryInput: (value: string) => void
debouncedSetSymbolQueryInput: (value: string) => void
setQueryInput: (value: string) => void
debouncedSetQueryInput: (value: string) => void
onSymbolSelected: (symbol: SymbolBlockInput) => void
setIsInputFocused: (isFocused: boolean) => void
}
function getSymbolSuggestionsQuery(queryInput: string): string {
return `${queryInput} fork:yes type:symbol count:50`
}
const LOADING = 'LOADING' as const
const QUERY_EXAMPLE = createQueryExampleFromString('{enter-regexp-pattern}')
export const NotebookSymbolBlockInput: React.FunctionComponent<NotebookSymbolBlockInputProps> = ({
sourcegraphSearchLanguageId,
id,
symbolQueryInput,
isLightTheme,
editor,
setEditor,
setSymbolQueryInput,
debouncedSetSymbolQueryInput,
onSymbolSelected,
setIsInputFocused,
...props
}) => {
const { isInputFocused } = useMonacoBlockInput({
editor,
id,
onInputChange: debouncedSetSymbolQueryInput,
preventNewLine: true,
...props,
})
useEffect(() => {
// setTimeout executes the editor focus in a separate run-loop which prevents adding a newline at the start of the input,
// if Enter key was used to show the inputs.
setTimeout(() => editor?.focus(), 0)
}, [editor])
useEffect(() => {
setIsInputFocused(isInputFocused)
return () => setIsInputFocused(false)
}, [isInputFocused, setIsInputFocused])
const addExampleFilter = useCallback(
(filterType: FilterType) => {
const { query, placeholderRange, filterRange } = updateQueryWithFilterAndExample(
symbolQueryInput,
filterType,
QUERY_EXAMPLE,
{ singular: false, negate: false, emptyValue: false }
)
setSymbolQueryInput(query)
const textModel = editor?.getModel()
if (!editor || !textModel) {
return
}
// Focus the selection in the next run-loop, since we have to wait for the Monaco editor to update.
setTimeout(() => {
const selectionRange = toMonacoSelection(toMonacoRange(placeholderRange, textModel))
editor.setSelection(selectionRange)
editor.revealRange(toMonacoRange(filterRange, textModel))
editor.focus()
}, 0)
},
[editor, symbolQueryInput, setSymbolQueryInput]
const fetchSymbolSuggestions = useCallback(
(query: string) =>
fetchSuggestions(
getSymbolSuggestionsQuery(query),
(suggestion): suggestion is SymbolMatch => suggestion.type === 'symbol',
symbol => symbol
),
[]
)
const symbolSuggestions = useObservable(
useMemo(
() =>
symbolQueryInput.length > 0
? fetchSuggestions(
getSymbolSuggestionsQuery(symbolQueryInput),
(suggestion): suggestion is SymbolMatch => suggestion.type === 'symbol',
symbol => symbol
).pipe(
// A small delay to prevent flickering loading spinner.
delay(300),
startWith(LOADING)
)
: of(undefined),
[symbolQueryInput]
)
const countSuggestions = useCallback(
(suggestions: SymbolMatch[]) => suggestions.reduce((count, suggestion) => count + suggestion.symbols.length, 0),
[]
)
const symbolSuggestionsCount = useMemo(() => {
if (!symbolSuggestions || symbolSuggestions === LOADING) {
return undefined
}
return symbolSuggestions.reduce((count, suggestion) => count + suggestion.symbols.length, 0)
}, [symbolSuggestions])
const renderSuggestions = useCallback(
(suggestions: SymbolMatch[]) => (
<SymbolSuggestions suggestions={suggestions} onSymbolSelected={onSymbolSelected} />
),
[onSymbolSelected]
)
return (
<div className={styles.input}>
<div>
<label htmlFor={`${id}-symbol-query-input`}>Find a symbol using a Sourcegraph search query</label>
<div
id={`${id}-symbol-query-input`}
className={classNames(
blockStyles.monacoWrapper,
isInputFocused && blockStyles.selected,
styles.queryInputMonacoWrapper
)}
>
<div className="d-flex">
<SyntaxHighlightedSearchQuery className={styles.typeSymbolQueryPart} query="type:symbol" />
</div>
<div className="flex-1">
<MonacoEditor
language={sourcegraphSearchLanguageId}
value={symbolQueryInput}
height={17}
isLightTheme={isLightTheme}
editorWillMount={noop}
onEditorCreated={setEditor}
options={{
...MONACO_BLOCK_INPUT_OPTIONS,
wordWrap: 'off',
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
}}
border={false}
/>
</div>
</div>
<div className="mt-1">
<Button
className="mr-1"
variant="secondary"
size="sm"
onClick={() => addExampleFilter(FilterType.repo)}
>
Filter symbols by repository
</Button>
<Button variant="secondary" size="sm" onClick={() => addExampleFilter(FilterType.file)}>
Filter symbols by file path
</Button>
</div>
<div className="mt-3 mb-1">
{symbolSuggestionsCount !== undefined && (
<strong>
{symbolSuggestionsCount} matching {pluralize('symbol', symbolSuggestionsCount)} found
</strong>
)}
{symbolSuggestions === LOADING && <strong>Searching for symbols...</strong>}
</div>
{symbolSuggestions && symbolSuggestions !== LOADING && (
<SymbolSuggestions suggestions={symbolSuggestions} onSymbolSelected={onSymbolSelected} />
)}
</div>
<SearchTypeSuggestionsInput<SymbolMatch>
label="Find a symbol using a Sourcegraph search query"
queryPrefix="type:symbol"
editor={editor}
fetchSuggestions={fetchSymbolSuggestions}
countSuggestions={countSuggestions}
renderSuggestions={renderSuggestions}
{...props}
/>
</div>
)
}

View File

@ -1,43 +0,0 @@
import React, { useCallback, useEffect } from 'react'
import { Block, BlockProps } from '..'
interface UseBlockFocusOptions extends Pick<BlockProps<Block>, 'onSelectBlock'> {
id: string
isSelected: boolean
isInputFocused: boolean
blockElement: HTMLElement | null
}
export const isMonacoEditorDescendant = (element: HTMLElement): boolean => element.closest('.monaco-editor') !== null
export const useBlockSelection = ({
id,
isSelected,
onSelectBlock,
blockElement,
isInputFocused,
}: UseBlockFocusOptions): {
onSelect: (event: React.MouseEvent | React.FocusEvent) => void
} => {
const onSelect = useCallback(
(event: React.MouseEvent | React.FocusEvent) => {
// Let Monaco input handle focus/click events
if (isMonacoEditorDescendant(event.target as HTMLElement)) {
return
}
onSelectBlock(id)
},
[id, onSelectBlock]
)
useEffect(() => {
if (isSelected && !isInputFocused) {
blockElement?.focus()
} else if (!isSelected) {
blockElement?.blur()
}
}, [isSelected, blockElement, isInputFocused])
return { onSelect }
}

View File

@ -1,68 +0,0 @@
import { useCallback, useMemo } from 'react'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { BlockProps } from '..'
interface UseBlockShortcutsOptions
extends Pick<
BlockProps,
'onMoveBlockSelection' | 'onDeleteBlock' | 'onRunBlock' | 'onDuplicateBlock' | 'onMoveBlock'
> {
id: string
onEnterBlock: () => void
}
export function isModifierKeyPressed(isMetaKey: boolean, isCtrlKey: boolean, isMacPlatform: boolean): boolean {
return (isMacPlatform && isMetaKey) || (!isMacPlatform && isCtrlKey)
}
export const useBlockShortcuts = ({
id,
onMoveBlockSelection,
onRunBlock,
onDeleteBlock,
onEnterBlock,
onMoveBlock,
onDuplicateBlock,
}: UseBlockShortcutsOptions): { onKeyDown: (event: React.KeyboardEvent) => void } => {
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
const onKeyDown = useCallback(
(event: React.KeyboardEvent): void => {
const isModifierKeyDown = isModifierKeyPressed(event.metaKey, event.ctrlKey, isMacPlatform)
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const direction = event.key === 'ArrowUp' ? 'up' : 'down'
if (isModifierKeyDown) {
onMoveBlock(id, direction)
// Prevent page scrolling in Firefox
event.preventDefault()
} else {
onMoveBlockSelection(id, direction)
}
} else if (event.key === 'Enter') {
if (isModifierKeyDown) {
onRunBlock(id)
} else {
onEnterBlock()
}
} else if (event.key === 'Delete' || (event.key === 'Backspace' && isModifierKeyDown)) {
onDeleteBlock(id)
} else if (event.key === 'd' && isModifierKeyDown) {
event.preventDefault()
onDuplicateBlock(id)
}
},
[
id,
isMacPlatform,
onMoveBlockSelection,
onRunBlock,
onDeleteBlock,
onEnterBlock,
onMoveBlock,
onDuplicateBlock,
]
)
return { onKeyDown }
}

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
export function isBlockInputFocused(id: string): boolean {
if (!document.activeElement) {
return false
}
const activeElement = document.activeElement as HTMLElement
if (!activeElement.closest(`[data-block-id="${id}"]`)) {
return false
}
const activeTagName = activeElement.tagName.toLowerCase()
return activeTagName === 'input' || activeTagName === 'textarea'
}
export function useIsBlockInputFocused(id: string): boolean {
const [isInputFocused, setIsInputFocused] = useState(false)
useEffect(() => {
const handleFocusChange = (): void => {
setIsInputFocused(isBlockInputFocused(id))
}
document.addEventListener('focusin', handleFocusChange)
document.addEventListener('focusout', handleFocusChange)
return () => {
document.removeEventListener('focusin', handleFocusChange)
document.removeEventListener('focusout', handleFocusChange)
}
}, [id])
return isInputFocused
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import * as Monaco from 'monaco-editor'
@ -39,10 +39,11 @@ export const MONACO_BLOCK_INPUT_OPTIONS: Monaco.editor.IStandaloneEditorConstruc
wordWrap: 'on',
}
interface UseMonacoBlockEditorOptions extends Pick<BlockProps, 'onRunBlock' | 'onSelectBlock'> {
interface UseMonacoBlockEditorOptions extends Pick<BlockProps, 'onRunBlock'> {
editor: Monaco.editor.IStandaloneCodeEditor | undefined
id: string
preventNewLine?: boolean
tabMovesFocus?: boolean
onInputChange: (value: string) => void
}
@ -52,14 +53,10 @@ export const useMonacoBlockInput = ({
editor,
id,
preventNewLine,
tabMovesFocus = true,
onRunBlock,
onInputChange,
onSelectBlock,
}: UseMonacoBlockEditorOptions): {
isInputFocused: boolean
} => {
const [isInputFocused, setIsInputFocused] = useState(false)
}: UseMonacoBlockEditorOptions): void => {
useEffect(() => {
if (!editor) {
return
@ -116,23 +113,19 @@ export const useMonacoBlockInput = ({
useEffect(() => {
if (!editor) {
setIsInputFocused(false)
return
}
const disposables = [
editor.onDidFocusEditorText(() => {
setIsInputFocused(true)
onSelectBlock(id)
if (tabMovesFocus) {
editor.createContextKey('editorTabMovesFocus', true)
}
}),
editor.onDidBlurEditorText(() => setIsInputFocused(false)),
editor.onDidDispose(() => setIsInputFocused(false)),
]
return () => {
for (const disposable of disposables) {
disposable.dispose()
}
}
}, [editor, id, setIsInputFocused, onSelectBlock])
return { isInputFocused }
}, [editor, id, tabMovesFocus])
}

View File

@ -97,8 +97,6 @@ export interface BlockProps<T extends Block = Block> {
onRunBlock(id: string): void
onDeleteBlock(id: string): void
onBlockInputChange(id: string, blockInput: BlockInput): void
onSelectBlock(id: string | null): void
onMoveBlockSelection(id: string, direction: BlockDirection): void
onMoveBlock(id: string, direction: BlockDirection): void
onDuplicateBlock(id: string): void
}

View File

@ -13,7 +13,6 @@ import {
import { BlockInit } from '..'
import { WebStory } from '../../components/WebStory'
import { RepositoryFields } from '../../graphql-operations'
import { NotebookComponent } from './NotebookComponent'
@ -37,9 +36,6 @@ const blocks: BlockInit[] = [
},
]
const resolveRevision = () => of({ commitID: 'commit1', defaultBranch: 'main', rootTreeURL: '' })
const fetchRepository = () => of({ id: 'repo' } as RepositoryFields)
add('default', () => (
<WebStory>
{props => (
@ -55,8 +51,6 @@ add('default', () => (
blocks={blocks}
settingsCascade={EMPTY_SETTINGS_CASCADE}
extensionsController={extensionsController}
fetchRepository={fetchRepository}
resolveRevision={resolveRevision}
authenticatedUser={null}
showSearchContext={true}
platformContext={NOOP_PLATFORM_CONTEXT}
@ -83,8 +77,6 @@ add('default read-only', () => (
blocks={blocks}
settingsCascade={EMPTY_SETTINGS_CASCADE}
extensionsController={extensionsController}
fetchRepository={fetchRepository}
resolveRevision={resolveRevision}
authenticatedUser={null}
showSearchContext={true}
platformContext={NOOP_PLATFORM_CONTEXT}

View File

@ -37,13 +37,12 @@ import { PageRoutes } from '../../routes.constants'
import { SearchStreamingProps } from '../../search'
import { NotebookComputeBlock } from '../blocks/compute/NotebookComputeBlock'
import { NotebookFileBlock } from '../blocks/file/NotebookFileBlock'
import { FileBlockValidationFunctions } from '../blocks/file/useFileBlockInputValidation'
import { NotebookMarkdownBlock } from '../blocks/markdown/NotebookMarkdownBlock'
import { NotebookQueryBlock } from '../blocks/query/NotebookQueryBlock'
import { NotebookSymbolBlock } from '../blocks/symbol/NotebookSymbolBlock'
import { isMonacoEditorDescendant } from '../blocks/useBlockSelection'
import { NotebookAddBlockButtons } from './NotebookAddBlockButtons'
import { focusBlock, useNotebookEventHandlers } from './useNotebookEventHandlers'
import { Notebook, CopyNotebookProps } from '.'
@ -53,8 +52,7 @@ export interface NotebookComponentProps
extends SearchStreamingProps,
ThemeProps,
TelemetryProps,
Omit<StreamingSearchResultsListProps, 'location' | 'allExpanded'>,
FileBlockValidationFunctions {
Omit<StreamingSearchResultsListProps, 'location' | 'allExpanded'> {
globbing: boolean
isReadOnly?: boolean
blocks: BlockInit[]
@ -237,6 +235,9 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
const blockToFocusAfterDelete = notebook.getNextBlockId(id) ?? notebook.getPreviousBlockId(id)
notebook.deleteBlockById(id)
setSelectedBlockId(blockToFocusAfterDelete)
if (blockToFocusAfterDelete) {
focusBlock(blockToFocusAfterDelete)
}
updateBlocks()
props.telemetryService.log('SearchNotebookDeleteBlock', { type: block?.type }, { type: block?.type })
@ -251,6 +252,7 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
}
notebook.moveBlockById(id, direction)
focusBlock(id)
updateBlocks()
props.telemetryService.log(
@ -271,6 +273,7 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
const duplicateBlock = notebook.duplicateBlockById(id)
if (duplicateBlock) {
setSelectedBlockId(duplicateBlock.id)
focusBlock(duplicateBlock.id)
}
if (duplicateBlock?.type === 'md') {
notebook.runBlockById(duplicateBlock.id)
@ -286,54 +289,19 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
[notebook, isReadOnly, props.telemetryService, setSelectedBlockId, updateBlocks]
)
const onSelectBlock = useCallback(
(id: string) => {
setSelectedBlockId(id)
},
[setSelectedBlockId]
const notebookEventHandlersProps = useMemo(
() => ({
notebook,
selectedBlockId,
setSelectedBlockId,
onMoveBlock,
onRunBlock,
onDeleteBlock,
onDuplicateBlock,
}),
[notebook, onDeleteBlock, onDuplicateBlock, onMoveBlock, onRunBlock, selectedBlockId]
)
const onMoveBlockSelection = useCallback(
(id: string, direction: BlockDirection) => {
const blockId = direction === 'up' ? notebook.getPreviousBlockId(id) : notebook.getNextBlockId(id)
if (blockId) {
setSelectedBlockId(blockId)
}
},
[notebook, setSelectedBlockId]
)
useEffect(() => {
const handleEventOutsideBlockWrapper = (event: MouseEvent | FocusEvent): void => {
const target = event.target as HTMLElement | null
if (!target?.closest('.block-wrapper') && !target?.closest('[data-reach-combobox-list]')) {
setSelectedBlockId(null)
}
}
const handleKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement
if (!selectedBlockId && event.key === 'ArrowDown') {
setSelectedBlockId(notebook.getFirstBlockId())
} else if (
event.key === 'Escape' &&
!isMonacoEditorDescendant(target) &&
target.tagName.toLowerCase() !== 'input'
) {
setSelectedBlockId(null)
}
}
document.addEventListener('keydown', handleKeyDown)
// Check all clicks on the document and deselect the currently selected block if it was triggered outside of a block.
document.addEventListener('mousedown', handleEventOutsideBlockWrapper)
// We're using the `focusin` event instead of the `focus` event, since the latter does not bubble up.
document.addEventListener('focusin', handleEventOutsideBlockWrapper)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleEventOutsideBlockWrapper)
document.removeEventListener('focusin', handleEventOutsideBlockWrapper)
}
}, [notebook, selectedBlockId, onMoveBlockSelection, setSelectedBlockId])
useNotebookEventHandlers(notebookEventHandlersProps)
const sourcegraphSearchLanguageId = useQueryIntelligence(fetchStreamSuggestions, {
patternType: SearchPatternType.literal,
@ -341,6 +309,13 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
interpretComments: true,
})
const sourcegraphSuggestionsSearchLanguageId = useQueryIntelligence(fetchStreamSuggestions, {
patternType: SearchPatternType.literal,
globbing: props.globbing,
interpretComments: true,
disablePatternSuggestions: true,
})
// Register dummy onCompletionSelected handler to prevent console errors
useEffect(() => {
const disposable = Monaco.editor.registerCommand('completionItemSelected', noop)
@ -414,10 +389,8 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
(block: Block) => {
const blockProps = {
...props,
onSelectBlock,
onRunBlock,
onBlockInputChange,
onMoveBlockSelection,
onDeleteBlock,
onMoveBlock,
onDuplicateBlock,
@ -435,6 +408,7 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
{...block}
{...blockProps}
hoverifier={hoverifier}
sourcegraphSearchLanguageId={sourcegraphSuggestionsSearchLanguageId}
extensionsController={extensionsController}
/>
)
@ -457,7 +431,7 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
{...block}
{...blockProps}
hoverifier={hoverifier}
sourcegraphSearchLanguageId={sourcegraphSearchLanguageId}
sourcegraphSearchLanguageId={sourcegraphSuggestionsSearchLanguageId}
extensionsController={extensionsController}
/>
)
@ -469,12 +443,11 @@ export const NotebookComponent: React.FunctionComponent<NotebookComponentProps>
onDeleteBlock,
onDuplicateBlock,
onMoveBlock,
onMoveBlockSelection,
onRunBlock,
onSelectBlock,
props,
selectedBlockId,
sourcegraphSearchLanguageId,
sourcegraphSuggestionsSearchLanguageId,
extensionsController,
hoverifier,
authenticatedUser,

View File

@ -13,7 +13,7 @@ import { NotebookFields, SearchPatternType } from '../../graphql-operations'
import { LATEST_VERSION } from '../../search/results/StreamingSearchResults'
import { parseBrowserRepoURL } from '../../util/url'
import { createNotebook } from '../backend'
import { fetchSuggestions } from '../blocks/suggestions'
import { fetchSuggestions } from '../blocks/suggestions/suggestions'
import { blockToGQLInput, serializeBlockToMarkdown } from '../serialize'
const DONE = 'DONE' as const

View File

@ -0,0 +1,121 @@
import { useCallback, useEffect, useMemo } from 'react'
import { isMacPlatform as isMacPlatformFn } from '@sourcegraph/common'
import { BlockDirection, BlockProps } from '..'
import { Notebook } from '.'
interface UseNotebookEventHandlersProps
extends Pick<BlockProps, 'onMoveBlock' | 'onRunBlock' | 'onDeleteBlock' | 'onDuplicateBlock'> {
notebook: Notebook
selectedBlockId: string | null
setSelectedBlockId: (blockId: string | null) => void
}
export function focusBlock(blockId: string): void {
document.querySelector<HTMLDivElement>(`[data-block-id="${blockId}"] .block`)?.focus()
}
export function isModifierKeyPressed(isMetaKey: boolean, isCtrlKey: boolean, isMacPlatform: boolean): boolean {
return (isMacPlatform && isMetaKey) || (!isMacPlatform && isCtrlKey)
}
export const isMonacoEditorDescendant = (element: HTMLElement): boolean => element.closest('.monaco-editor') !== null
export function useNotebookEventHandlers({
notebook,
selectedBlockId,
setSelectedBlockId,
onMoveBlock,
onRunBlock,
onDeleteBlock,
onDuplicateBlock,
}: UseNotebookEventHandlersProps): void {
const onMoveBlockSelection = useCallback(
(id: string, direction: BlockDirection) => {
const blockId = direction === 'up' ? notebook.getPreviousBlockId(id) : notebook.getNextBlockId(id)
if (blockId) {
setSelectedBlockId(blockId)
focusBlock(blockId)
}
},
[notebook, setSelectedBlockId]
)
const isMacPlatform = useMemo(() => isMacPlatformFn(), [])
useEffect(() => {
const handleMouseDownOrFocusIn = (event: MouseEvent | FocusEvent): void => {
const target = event.target as HTMLElement | null
const blockWrapper = target?.closest<HTMLDivElement>('.block-wrapper')
if (!blockWrapper) {
setSelectedBlockId(null)
return
}
const blockId = blockWrapper.dataset.blockId
if (!blockId) {
return
}
setSelectedBlockId(blockId)
}
const handleKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement
if (!selectedBlockId && event.key === 'ArrowDown') {
setSelectedBlockId(notebook.getFirstBlockId())
} else if (
event.key === 'Escape' &&
!isMonacoEditorDescendant(target) &&
target.tagName.toLowerCase() !== 'input'
) {
setSelectedBlockId(null)
}
if (!selectedBlockId) {
return
}
const isModifierKeyDown = isModifierKeyPressed(event.metaKey, event.ctrlKey, isMacPlatform)
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const direction = event.key === 'ArrowUp' ? 'up' : 'down'
if (isModifierKeyDown) {
onMoveBlock(selectedBlockId, direction)
// Prevent page scrolling in Firefox
event.preventDefault()
} else {
onMoveBlockSelection(selectedBlockId, direction)
}
} else if (event.key === 'Enter' && isModifierKeyDown) {
onRunBlock(selectedBlockId)
} else if (event.key === 'Delete' || (event.key === 'Backspace' && isModifierKeyDown)) {
onDeleteBlock(selectedBlockId)
} else if (event.key === 'd' && isModifierKeyDown) {
event.preventDefault()
onDuplicateBlock(selectedBlockId)
}
}
document.addEventListener('keydown', handleKeyDown)
// Check all clicks on the document and deselect the currently selected block if it was triggered outside of a block.
document.addEventListener('mousedown', handleMouseDownOrFocusIn)
// We're using the `focusin` event instead of the `focus` event, since the latter does not bubble up.
document.addEventListener('focusin', handleMouseDownOrFocusIn)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDownOrFocusIn)
document.removeEventListener('focusin', handleMouseDownOrFocusIn)
}
}, [
notebook,
selectedBlockId,
onMoveBlockSelection,
setSelectedBlockId,
isMacPlatform,
onMoveBlock,
onRunBlock,
onDeleteBlock,
onDuplicateBlock,
])
}

View File

@ -11,7 +11,7 @@ import { aggregateStreamingSearch } from '@sourcegraph/shared/src/search/stream'
import { Alert, LoadingSpinner, useObservable } from '@sourcegraph/wildcard'
import { createPlatformContext } from '../../platform/context'
import { fetchHighlightedFileLineRanges, fetchRepository, resolveRevision } from '../../repo/backend'
import { fetchHighlightedFileLineRanges } from '../../repo/backend'
import { eventLogger } from '../../tracking/eventLogger'
import { fetchNotebook } from '../backend'
import { convertNotebookTitleToFileName } from '../serialize'
@ -71,9 +71,7 @@ export const EmbeddedNotebookPage: React.FunctionComponent<EmbeddedNotebookPageP
onUpdateBlocks={noop}
viewerCanManage={false}
globbing={true}
fetchRepository={fetchRepository}
fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges}
resolveRevision={resolveRevision}
streamSearch={aggregateStreamingSearch}
telemetryService={eventLogger}
platformContext={platformContext}

View File

@ -12,7 +12,6 @@ import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { Block, BlockInit } from '..'
import { NotebookFields } from '../../graphql-operations'
import { fetchRepository, resolveRevision } from '../../repo/backend'
import { SearchStreamingProps } from '../../search'
import { CopyNotebookProps } from '../notebook'
import { NotebookComponent } from '../notebook/NotebookComponent'
@ -31,16 +30,12 @@ export interface NotebookContentProps
isEmbedded?: boolean
onUpdateBlocks: (blocks: Block[]) => void
onCopyNotebook: (props: Omit<CopyNotebookProps, 'title'>) => Observable<NotebookFields>
fetchRepository: typeof fetchRepository
resolveRevision: typeof resolveRevision
}
export const NotebookContent: React.FunctionComponent<NotebookContentProps> = ({
viewerCanManage,
blocks,
onUpdateBlocks,
resolveRevision,
fetchRepository,
...props
}) => {
const initializerBlocks: BlockInit[] = useMemo(
@ -80,8 +75,6 @@ export const NotebookContent: React.FunctionComponent<NotebookContentProps> = ({
isReadOnly={!viewerCanManage}
blocks={initializerBlocks}
onSerializeBlocks={viewerCanManage ? onUpdateBlocks : noop}
resolveRevision={resolveRevision}
fetchRepository={fetchRepository}
/>
)
}

View File

@ -269,8 +269,6 @@ export const NotebookPage: React.FunctionComponent<NotebookPageProps> = ({
viewerCanManage={notebookOrError.viewerCanManage}
blocks={notebookOrError.blocks}
onUpdateBlocks={onUpdateBlocks}
fetchRepository={fetchRepository}
resolveRevision={resolveRevision}
onCopyNotebook={onCopyNotebook}
exportedFileName={exportedFileName}
/>

View File

@ -31,7 +31,6 @@ import { SearchStreamingProps } from '../../search'
import { useSearchStack, useExperimentalFeatures } from '../../stores'
import { basename } from '../../util/path'
import { toTreeURL } from '../../util/url'
import { fetchRepository, resolveRevision } from '../backend'
import { FilePathBreadcrumbs } from '../FilePathBreadcrumbs'
import { HoverThresholdProps } from '../RepoContainer'
import { RepoHeaderContributionsLifecycleProps } from '../RepoHeader'
@ -341,8 +340,6 @@ export const BlobPage: React.FunctionComponent<Props> = props => {
<RenderedNotebookMarkdown
{...props}
markdown={blobInfoOrError.content}
resolveRevision={resolveRevision}
fetchRepository={fetchRepository}
onCopyNotebook={onCopyNotebook}
showSearchContext={showSearchContext}
exportedFileName={basename(blobInfoOrError.filePath)}