mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 20:31:48 +00:00
notebooks: query input for file block (#32506)
This commit is contained in:
parent
70a8af55d9
commit
4abf00bde6
@ -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(() => {
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
))
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
31
client/web/src/notebooks/blocks/useIsBlockInputFocused.ts
Normal file
31
client/web/src/notebooks/blocks/useIsBlockInputFocused.ts
Normal 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
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
121
client/web/src/notebooks/notebook/useNotebookEventHandlers.ts
Normal file
121
client/web/src/notebooks/notebook/useNotebookEventHandlers.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user