Implement Semantic Search (#440)

This commit is contained in:
Tan Nhu 2023-09-12 22:16:40 +00:00 committed by Harness
parent c41d944d44
commit ce6d798fb2
27 changed files with 858 additions and 129 deletions

View File

@ -35,6 +35,7 @@ module.exports = {
'./Settings': './src/pages/RepositorySettings/RepositorySettings.tsx',
'./Webhooks': './src/pages/Webhooks/Webhooks.tsx',
'./WebhookNew': './src/pages/WebhookNew/WebhookNew.tsx',
'./Search': './src/pages/Search/Search.tsx',
'./WebhookDetails': './src/pages/WebhookDetails/WebhookDetails.tsx',
'./NewRepoModalButton': './src/components/NewRepoModalButton/NewRepoModalButton.tsx'
},

View File

@ -73,7 +73,7 @@ export interface CODERoutes {
toCODEWebhookNew: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEWebhookDetails: (args: Required<Pick<CODEProps, 'repoPath' | 'webhookId'>>) => string
toCODESettings: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODESearch: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEExecutions: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline'>>) => string
toCODEExecution: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline' | 'execution'>>) => string
toCODESecret: (args: Required<Pick<CODEProps, 'space' | 'secret'>>) => string
@ -126,6 +126,7 @@ export const routes: CODERoutes = {
toCODEBranches: ({ repoPath }) => `/${repoPath}/branches`,
toCODETags: ({ repoPath }) => `/${repoPath}/tags`,
toCODESettings: ({ repoPath }) => `/${repoPath}/settings`,
toCODESearch: ({ repoPath }) => `/${repoPath}/search`,
toCODEWebhooks: ({ repoPath }) => `/${repoPath}/webhooks`,
toCODEWebhookNew: ({ repoPath }) => `/${repoPath}/webhooks/new`,
toCODEWebhookDetails: ({ repoPath, webhookId }) => `/${repoPath}/webhook/${webhookId}`,

View File

@ -30,6 +30,7 @@ import { useStrings } from 'framework/strings'
import ExecutionList from 'pages/ExecutionList/ExecutionList'
import Execution from 'pages/Execution/Execution'
import Secret from 'pages/Secret/Secret'
import Search from 'pages/Search/Search'
import AddUpdatePipeline from 'pages/AddUpdatePipeline/AddUpdatePipeline'
import { useAppContext } from 'AppContext'
@ -249,6 +250,12 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</LayoutWithSideNav>
</Route>
<Route path={routes.toCODESearch({ repoPath })} exact>
<LayoutWithSideNav title={getString('pageTitle.search')}>
<Search />
</LayoutWithSideNav>
</Route>
<Route
path={routes.toCODEFileEdit({
repoPath,

View File

@ -11,7 +11,7 @@ import { EditorView, keymap, placeholder as placeholderExtension } from '@codemi
import { Compartment, EditorState, Extension } from '@codemirror/state'
import { color } from '@uiw/codemirror-extensions-color'
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link'
import { githubLight as theme } from '@uiw/codemirror-themes-all'
import { githubLight, githubDark } from '@uiw/codemirror-themes-all'
import css from './Editor.module.scss'
export interface EditorProps {
@ -28,6 +28,7 @@ export interface EditorProps {
setDirty?: React.Dispatch<React.SetStateAction<boolean>>
onChange?: (doc: Text, viewUpdate: ViewUpdate, isDirty: boolean) => void
onViewUpdate?: (viewUpdate: ViewUpdate) => void
darkTheme?: boolean
}
export const Editor = React.memo(function CodeMirrorReactEditor({
@ -43,8 +44,10 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
viewRef,
setDirty,
onChange,
onViewUpdate
onViewUpdate,
darkTheme
}: EditorProps) {
const contentRef = useRef(content)
const view = useRef<EditorView>()
const ref = useRef<HTMLDivElement>()
const languageConfig = useMemo(() => new Compartment(), [])
@ -70,7 +73,7 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
color,
hyperLink,
theme,
darkTheme ? githubDark : githubLight,
EditorView.lineWrapping,
@ -136,5 +139,14 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
}
}, [filename, forMarkdown, view, languageConfig, markdownLanguageSupport])
useEffect(() => {
if (contentRef.current !== content) {
contentRef.current = content
viewRef?.current?.dispatch({
changes: { from: 0, to: viewRef?.current?.state.doc.length, insert: content }
})
}
}, [content, viewRef])
return <Container ref={ref} className={cx(css.editor, className)} style={style} />
})

View File

@ -1,5 +1,5 @@
import React, { Fragment } from 'react'
import { Container, Layout, Text, PageHeader } from '@harnessio/uicore'
import { Container, Layout, Text, PageHeader, PageHeaderProps } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
import { Link, useParams } from 'react-router-dom'
@ -19,33 +19,35 @@ interface RepositoryPageHeaderProps extends Optional<Pick<GitInfoProps, 'repoMet
title: string | JSX.Element
dataTooltipId: string
extraBreadcrumbLinks?: BreadcrumbLink[]
className?: string
content?: PageHeaderProps['content']
}
export function RepositoryPageHeader({
repoMetadata,
title,
dataTooltipId,
extraBreadcrumbLinks = []
extraBreadcrumbLinks = [],
className,
content
}: RepositoryPageHeaderProps) {
const { gitRef } = useParams<CODEProps>()
const { getString } = useStrings()
const space = useGetSpaceParam()
const { routes } = useAppContext()
if (!repoMetadata) {
return null
}
return (
<PageHeader
className={className}
content={content}
title=""
breadcrumbs={
<Container className={css.header}>
<Layout.Horizontal spacing="small" className={css.breadcrumb}>
<Link to={routes.toCODERepositories({ space })}>{getString('repositories')}</Link>
<Icon name="main-chevron-right" size={8} color={Color.GREY_500} />
<Link to={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef })}>
{repoMetadata.uid}
<Link to={routes.toCODERepository({ repoPath: (repoMetadata?.path as string) || '', gitRef })}>
{repoMetadata?.uid || ''}
</Link>
{extraBreadcrumbLinks.map(link => (
<Fragment key={link.url}>

View File

@ -16,6 +16,7 @@ interface SearchInputWithSpinnerProps {
icon?: IconName
spinnerIcon?: IconName
spinnerPosition?: 'left' | 'right'
onSearch?: (searchTerm: string) => void
}
export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
@ -26,7 +27,8 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
placeholder,
icon = 'search',
spinnerIcon = 'steps-spinner',
spinnerPosition = 'left'
spinnerPosition = 'left',
onSearch
}) => {
const { getString } = useStrings()
const spinner = <Icon name={spinnerIcon as IconName} color={Color.PRIMARY_7} />
@ -37,6 +39,7 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
<Layout.Horizontal className={css.layout}>
<Render when={loading && !spinnerOnRight}>{spinner}</Render>
<TextInput
type="search"
value={query}
wrapperClassName={cx(css.wrapper, { [css.spinnerOnRight]: spinnerOnRight })}
className={css.input}
@ -45,7 +48,14 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
style={{ width }}
autoFocus
onFocus={event => event.target.select()}
onInput={event => setQuery(event.currentTarget.value || '')}
onInput={event => {
setQuery(event.currentTarget.value || '')
}}
onKeyDown={(e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') {
onSearch?.((e as unknown as React.FormEvent<HTMLInputElement>).currentTarget.value || '')
}
}}
/>
<Render when={loading && spinnerOnRight}>{spinner}</Render>
</Layout.Horizontal>

View File

@ -48,7 +48,7 @@
width: min(calc(100vw - var(--nav-menu-width)), 840px) !important;
height: 100vh;
position: fixed;
left: 5px;
left: 241px;
top: -5px;
> div {
@ -79,6 +79,13 @@
background-color: rgba(26, 26, 26, 0.4);
}
:global {
.bp3-popover-arrow {
left: -11px !important;
transform: rotate(180deg);
}
}
> div {
height: 100%;
width: 100%;

View File

@ -0,0 +1,48 @@
.main {
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}

View File

@ -0,0 +1,3 @@
/* eslint-disable */
// This is an auto-generated file
export declare const main: string

View File

@ -0,0 +1,8 @@
import React from 'react'
import cx from 'classnames'
import SplitPane, { type SplitPaneProps } from 'react-split-pane'
import css from './Split.module.scss'
export const Split: React.FC<SplitPaneProps> = ({ className, ...props }) => (
<SplitPane className={cx(css.main, className)} {...props} />
)

View File

@ -17,6 +17,7 @@ export interface StringsMap {
addNewFile: string
addReadMe: string
admin: string
aiSearch: string
all: string
allBranches: string
allComments: string
@ -65,6 +66,7 @@ export interface StringsMap {
cloneText: string
close: string
closed: string
codeSearch: string
comment: string
commentDeleted: string
commit: string
@ -307,6 +309,7 @@ export interface StringsMap {
'pageTitle.repositories': string
'pageTitle.repository': string
'pageTitle.repositorySettings': string
'pageTitle.search': string
'pageTitle.secrets': string
'pageTitle.signin': string
'pageTitle.spaceSettings': string
@ -336,6 +339,7 @@ export interface StringsMap {
'pipelines.run': string
'pipelines.saveAndRun': string
'pipelines.time': string
poweredByAI: string
'pipelines.updated': string
'pipelines.yamlPath': string
'plugins.addAPlugin': string
@ -482,6 +486,7 @@ export interface StringsMap {
scrollToTop: string
search: string
searchBranches: string
searchResult: string
secret: string
'secrets.createSecret': string
'secrets.createSuccess': string
@ -527,6 +532,7 @@ export interface StringsMap {
'spaceSetting.setting': string
spaces: string
sslVerificationLabel: string
startSearching: string
status: string
submitReview: string
success: string
@ -586,6 +592,7 @@ export interface StringsMap {
viewAllTags: string
viewCommitDetails: string
viewFile: string
viewFileHistory: string
viewFiles: string
viewRaw: string
viewRepo: string

View File

@ -96,6 +96,7 @@ pageTitle:
pipelines: Pipelines
secrets: Secrets
executions: Executions
search: Search powered by AI
repos:
name: Repo Name
data: Repo Data
@ -482,7 +483,7 @@ newTag: New Tag
overview: Overview
fileTooLarge: File is too large to open. {download}
clickHereToDownload: Click here to download.
viewFile: View the file at this point in the history
viewFileHistory: View the file at this point in the history
viewRepo: View the repository at this point in the history
hideCommitHistory: Renamed from {file} - Hide History
showCommitHistory: Renamed from {file} - Show History
@ -669,6 +670,12 @@ secrets:
failedToDeleteSecret: Failed to delete Secret. Please try again.
deleteSecret: Delete Secrets
userUpdateSuccess: 'User updated successfully'
viewFile: View File
searchResult: 'Search Result {count}'
aiSearch: AI Search
codeSearch: Code Search
startSearching: Begin search by describing what you are looking for.
poweredByAI: Unlock the power of AI with Semantic Code search. Try phrases like "Locate the code for authentication".
run: Run
plugins:
title: Plugins

View File

@ -11,6 +11,7 @@
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
.settings {
margin: 0 var(--spacing-medium);

View File

@ -126,15 +126,25 @@ export const DefaultMenu: React.FC = () => {
})}
/>
)}
<NavMenuItem
data-code-repo-section="settings"
data-code-repo-section="pipelines"
isSubLink
label={getString('settings')}
to={routes.toCODESettings({
label={getString('pageTitle.pipelines')}
to={routes.toCODEPipelines({
repoPath
})}
/>
{!standalone && (
<NavMenuItem
data-code-repo-section="search"
isSubLink
label={getString('search')}
to={routes.toCODESearch({
repoPath
})}
/>
)}
</Layout.Vertical>
</Container>
</Render>

View File

@ -1,53 +1,6 @@
.main {
min-height: var(--page-height);
background-color: var(--primary-bg) !important;
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}
.container {

View File

@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react'
import cx from 'classnames'
import { useParams } from 'react-router-dom'
import { useGet } from 'restful-react'
import SplitPane from 'react-split-pane'
import { routes, type CODEProps } from 'RouteDefinitions'
import type { TypesExecution } from 'services/code'
import ExecutionStageList from 'components/ExecutionStageList/ExecutionStageList'
@ -12,6 +11,7 @@ import { getErrorMessage, voidFn } from 'utils/Utils'
import { useStrings } from 'framework/strings'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { Split } from 'components/Split/Split'
import { ExecutionPageHeader } from 'components/ExecutionPageHeader/ExecutionPageHeader'
import usePipelineEventStream from 'hooks/usePipelineEventStream'
import { ExecutionState } from 'components/ExecutionStatus/ExecutionStatus'
@ -102,7 +102,7 @@ const Execution = () => {
}}>
<LoadingSpinner visible={loading || isInitialLoad} withBorder={!!execution && isInitialLoad} />
{execution && (
<SplitPane split="vertical" size={300} minSize={200} maxSize={400}>
<Split split="vertical" size={300} minSize={200} maxSize={400}>
<ExecutionStageList
stages={execution?.stages || []}
setSelectedStage={setSelectedStage}
@ -111,7 +111,7 @@ const Execution = () => {
{selectedStage && (
<Console stage={execution?.stages?.[selectedStage - 1]} repoPath={repoMetadata?.path as string} />
)}
</SplitPane>
</Split>
)}
</PageBody>
</Container>

View File

@ -139,53 +139,6 @@
}
}
}
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}
.status {

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Falsy, Match, Render, Truthy } from 'react-jsx-match'
import { /*CheckCircle,*/ NavArrowRight } from 'iconoir-react'
import SplitPane from 'react-split-pane'
import { get } from 'lodash-es'
import cx from 'classnames'
import { useHistory } from 'react-router-dom'
@ -24,6 +23,7 @@ import type { GitInfoProps } from 'utils/GitUtils'
import { useAppContext } from 'AppContext'
import { useQueryParams } from 'hooks/useQueryParams'
import { useStrings } from 'framework/strings'
import { Split } from 'components/Split/Split'
import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer'
import type { PRChecksDecisionResult } from 'hooks/usePRChecksDecision'
import type { TypesCheck } from 'services/code'
@ -56,7 +56,7 @@ export const Checks: React.FC<ChecksProps> = props => {
<Container className={css.main}>
<Match expr={props.prChecksDecisionResult?.overallStatus}>
<Truthy>
<SplitPane
<Split
split="vertical"
size="calc(100% - 400px)"
minSize={800}
@ -118,7 +118,7 @@ export const Checks: React.FC<ChecksProps> = props => {
</Falsy>
</Match>
</Container>
</SplitPane>
</Split>
</Truthy>
<Falsy>
<Container flex={{ align: 'center-center' }} height="90%">

View File

@ -1,5 +1,6 @@
.main {
padding: var(--spacing-large) var(--spacing-xlarge) 0 var(--spacing-xlarge) !important;
position: relative;
div[class*='TextInput'] {
margin-bottom: 0 !important;
@ -54,7 +55,31 @@
}
}
.breadcrumbItem {
white-space: nowrap !important;
.searchBox {
position: absolute;
right: 16px;
top: -50px;
z-index: 2;
padding-bottom: 0 !important;
margin: 0;
input,
input:focus {
border: 1px solid var(--ai-purple-600) !important;
}
input {
width: 350px !important;
}
svg path {
fill: var(--ai-purple-600) !important;
}
img {
position: absolute;
top: 5px;
right: 13px;
}
}
}

View File

@ -1,7 +1,7 @@
/* eslint-disable */
// This is an auto-generated file
export declare const breadcrumbItem: string
export declare const btnColorFix: string
export declare const main: string
export declare const refRoot: string
export declare const rootSlash: string
export declare const searchBox: string

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { Container, Layout, Button, FlexExpander, ButtonVariation, Text } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color } from '@harnessio/design-system'
@ -12,6 +12,8 @@ import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import { useCreateBranchModal } from 'components/CreateBranchModal/CreateBranchModal'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { permissionProps } from 'utils/Utils'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import svg from './search-background.svg'
import css from './ContentHeader.module.scss'
export function ContentHeader({
@ -21,11 +23,9 @@ export function ContentHeader({
resourceContent
}: Pick<GitInfoProps, 'repoMetadata' | 'gitRef' | 'resourcePath' | 'resourceContent'>) {
const { getString } = useStrings()
const { routes } = useAppContext()
const { routes, standalone, hooks } = useAppContext()
const history = useHistory()
const _isDir = isDir(resourceContent)
const { standalone } = useAppContext()
const { hooks } = useAppContext()
const space = useGetSpaceParam()
const permPushResult = hooks?.usePermissionTranslate?.(
@ -62,6 +62,7 @@ export function ContentHeader({
return { href, text: _path }
})
}, [resourcePath, gitRef, repoMetadata.path, routes])
const [search, setSearch] = useState('')
return (
<Container className={css.main}>
@ -139,6 +140,25 @@ export function ContentHeader({
</>
)}
</Layout.Horizontal>
{!standalone && (
<Container className={css.searchBox}>
<SearchInputWithSpinner
placeholder={getString('codeSearch')}
spinnerPosition="right"
query={search}
setQuery={setSearch}
onSearch={() => {
history.push({
pathname: routes.toCODESearch({
repoPath: repoMetadata.path as string
}),
search: `q=${search}`
})
}}
/>
{!search && <img src={svg} width={95} height={22} />}
</Container>
)}
</Container>
)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -248,9 +248,8 @@ export function FileContent({
<Match expr={isViewable}>
<Falsy>
<Center>
rawURL: {rawURL}
<Link
to={rawURL} // TODO: Link component generates wrong copy link
to={rawURL}
onClick={e => {
Utils.stopEvent(e)
downloadFile({ repoMetadata, resourcePath, gitRef, filename })

View File

@ -0,0 +1,229 @@
@import 'src/utils/utils';
.main {
--header-height: 128px;
--border-color: var(--grey-100);
min-height: var(--page-height);
background-color: var(--primary-bg) !important;
.pageHeader {
flex-direction: column;
height: var(--header-height);
align-items: normal !important;
justify-content: flex-start !important;
padding-top: 0 !important;
[class*='breadcrumbs'] > [class*='module_header'] {
padding-top: 13px !important;
}
.searchBox {
display: flex;
flex-direction: row;
> div {
flex-grow: 1;
div {
width: 100%;
}
}
input {
width: calc(100% - 8px) !important;
border: 1px solid var(--ai-purple-600) !important;
}
svg path {
fill: var(--ai-purple-600) !important;
}
}
& + div {
--page-header-height: var(--header-height) !important;
}
}
.split {
> div:first-of-type {
background-color: #fbfcfd;
overflow: auto;
}
> div:last-of-type {
background-color: var(--white);
overflow: scroll;
}
.searchResult {
padding: var(--spacing-medium) var(--spacing-large) var(--spacing-large) var(--spacing-xlarge);
.resultTitle {
text-transform: uppercase;
font-size: 10px;
font-weight: 600;
color: var(--grey-400);
}
.result {
padding: var(--spacing-medium);
border: 1px solid rgba(243, 243, 250, 1);
border-radius: 5px;
background-color: var(--white);
&.selected {
border-color: rgba(217, 218, 229, 1);
background-color: rgba(246, 241, 255, 1);
box-shadow: 0px 0.5px 2px 0px rgba(96, 97, 112, 0.16), 0px 0px 1px 0px rgba(40, 41, 61, 0.08);
}
&:hover:not(.selected) {
border-color: rgba(217, 218, 229, 1);
background-color: rgba(246, 241, 255, 0.5);
}
.layout {
align-items: baseline;
}
.texts {
flex-grow: 1;
}
.filename {
font-size: 12px;
font-weight: 600;
color: rgba(79, 81, 98, 1);
}
.path {
font-size: 11px;
font-weight: 500;
color: rgba(146, 147, 171, 1);
}
.aiLabel {
background: var(--ai-purple-100);
color: var(--ai-purple-600);
text-transform: uppercase;
font-size: 8px;
font-weight: 800;
text-align: center;
padding: 3px 6px;
border-radius: 2px;
white-space: nowrap;
}
}
}
:global {
.Resizer.vertical {
width: 13px;
background-color: var(--border-color);
opacity: 1;
&:active,
&:focus,
&:hover {
background-color: var(--primary-6);
border-color: transparent !important;
}
}
}
.preview {
height: 100%;
position: relative;
&.noResult {
> * {
visibility: hidden;
}
}
.filePath {
height: 45px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 var(--spacing-medium);
> div:first-of-type {
flex-grow: 1;
width: calc(100% - 150px);
}
button {
white-space: nowrap;
}
.pathText {
align-self: center;
color: var(--grey-500);
}
:global {
.bp3-breadcrumb,
.bp3-breadcrumb-current,
.bp3-breadcrumbs-collapsed {
white-space: nowrap !important;
font-size: 12px;
font-weight: 400;
color: var(--grey-500);
}
.bp3-breadcrumbs > li::after {
background: none;
content: '/';
color: var(--grey-500);
background: none;
text-align: center;
height: 100%;
}
.bp3-breadcrumbs-collapsed {
background: var(--grey-100);
}
}
}
.fileContent {
flex-grow: 1;
height: calc(100% - 45px);
overflow: auto;
:global {
.cm-editor {
border: none;
.cm-scroller {
padding: 0;
.cm-line {
&,
* {
@include mono-font;
}
}
}
.cm-gutters {
border-right: none;
.cm-gutterElement {
padding-left: 30px;
padding-right: 6px;
}
}
}
}
}
.highlightLineNumber {
background-color: var(--ai-purple-100);
}
}
}
}

View File

@ -0,0 +1,21 @@
/* eslint-disable */
// This is an auto-generated file
export declare const aiLabel: string
export declare const fileContent: string
export declare const filename: string
export declare const filePath: string
export declare const highlightLineNumber: string
export declare const layout: string
export declare const main: string
export declare const noResult: string
export declare const pageHeader: string
export declare const path: string
export declare const pathText: string
export declare const preview: string
export declare const result: string
export declare const resultTitle: string
export declare const searchBox: string
export declare const searchResult: string
export declare const selected: string
export declare const split: string
export declare const texts: string

View File

@ -0,0 +1,332 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
ButtonSize,
ButtonVariation,
Container,
Layout,
PageBody,
stringSubstitute,
Text,
useToaster
} from '@harnessio/uicore'
import cx from 'classnames'
import { lineNumbers, ViewUpdate } from '@codemirror/view'
import { Breadcrumbs, IBreadcrumbProps } from '@blueprintjs/core'
import { Link, useHistory, useLocation } from 'react-router-dom'
import { EditorView } from '@codemirror/view'
import { Match, Truthy, Falsy } from 'react-jsx-match'
import { Icon } from '@harnessio/icons'
import { useMutate } from 'restful-react'
import { Editor } from 'components/Editor/Editor'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { Split } from 'components/Split/Split'
import { CodeIcon, decodeGitContent } from 'utils/GitUtils'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { useQueryParams } from 'hooks/useQueryParams'
import { useAppContext } from 'AppContext'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import { voidFn, getErrorMessage, ButtonRoleProps } from 'utils/Utils'
import type { RepoFileContent } from 'services/code'
import { useShowRequestError } from 'hooks/useShowRequestError'
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { useGetResourceContent } from 'hooks/useGetResourceContent'
import { addClassToLinesExtension } from 'utils/codemirror/addClassToLinesExtension'
import css from './Search.module.scss'
export default function Search() {
const { showError } = useToaster()
const history = useHistory()
const location = useLocation()
const highlightedLines = useRef<number[]>([])
const [highlightlLineNumbersExtension, updateHighlightlLineNumbers] = useMemo(
() => addClassToLinesExtension([], css.highlightLineNumber),
[]
)
const extensions = useMemo(() => {
return [
lineNumbers({
formatNumber: (lineNo: number) => lineNo.toString()
}),
highlightlLineNumbersExtension
]
}, [highlightlLineNumbersExtension])
const viewRef = useRef<EditorView>()
const { getString } = useStrings()
const { routes } = useAppContext()
const { q } = useQueryParams<{ q: string }>()
const [searchTerm, setSearchTerm] = useState(q || '')
const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata()
const [resourcePath, setResourcePath] = useState('')
const [filename, setFileName] = useState('')
const gitRef = useMemo(() => repoMetadata?.default_branch || '', [repoMetadata])
const breadcrumbs = useMemo(() => {
return repoMetadata?.path
? resourcePath.split('/').map((_path, index, paths) => {
const pathAtIndex = paths.slice(0, index + 1).join('/')
const href = routes.toCODERepository({
repoPath: repoMetadata.path as string,
gitRef,
resourcePath: pathAtIndex
})
return { href, text: _path }
})
: []
}, [resourcePath, repoMetadata, gitRef, routes])
const onSelectResult = useCallback(
(fileName: string, filePath: string, _content: string, _highlightedLines: number[]) => {
updateHighlightlLineNumbers(_highlightedLines, viewRef.current)
highlightedLines.current = _highlightedLines
setFileName(fileName)
setResourcePath(filePath)
},
[updateHighlightlLineNumbers]
)
const {
data: resourceContent,
error: resourceError = null,
loading: resourceLoading
} = useGetResourceContent({ repoMetadata, gitRef, resourcePath, includeCommit: false, lazy: !resourcePath })
const fileContent = useMemo(
() =>
resourceContent?.path === resourcePath
? decodeGitContent((resourceContent?.content as RepoFileContent)?.data)
: '',
[resourceContent?.content, resourceContent?.path, resourcePath]
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const onViewUpdate = useCallback(({ view, docChanged }: ViewUpdate) => {
const firstLine = (highlightedLines.current || [])[0]
if (docChanged && firstLine > 0 && view.state.doc.lines >= firstLine) {
view.dispatch({
effects: EditorView.scrollIntoView(view.state.doc.line(firstLine).from, { y: 'start', yMargin: 18 * 2 })
})
}
}, [])
const [loadingSearch, setLoadingSearch] = useState(false)
const { mutate: sendSearch } = useMutate<SearchResultType[]>({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata?.path}/+/semantic/search`
})
const [searchResult, setSearchResult] = useState<SearchResultType[]>([])
const performSearch = useCallback(() => {
setLoadingSearch(true)
history.replace({ pathname: location.pathname, search: `q=${searchTerm}` })
sendSearch({ query: searchTerm })
.then(response => {
setSearchResult(response)
})
.catch(exception => {
showError(getErrorMessage(exception), 0)
})
.finally(() => {
setLoadingSearch(false)
})
}, [searchTerm, history, location, sendSearch, showError])
useEffect(() => {
if (q && repoMetadata?.path) {
performSearch()
}
}, [repoMetadata?.path]) // eslint-disable-line react-hooks/exhaustive-deps
useShowRequestError(resourceError)
return (
<Container className={css.main}>
<RepositoryPageHeader
repoMetadata={repoMetadata}
title={getString('search')}
dataTooltipId="semanticSearch"
content={
<Container className={css.searchBox}>
<SearchInputWithSpinner
spinnerPosition="right"
query={searchTerm}
setQuery={setSearchTerm}
onSearch={performSearch}
/>
<Button
variation={ButtonVariation.PRIMARY}
text={getString('search')}
disabled={!searchTerm.trim()}
onClick={performSearch}
loading={loadingSearch}
/>
</Container>
}
className={css.pageHeader}
/>
<PageBody error={getErrorMessage(error)} retryOnError={voidFn(refetch)}>
<LoadingSpinner visible={loading || resourceLoading || loadingSearch} withBorder />
<Match expr={q}>
<Truthy>
<Split split="vertical" className={css.split} size={450} minSize={300} maxSize={700} primary="first">
<SearchResults onSelect={onSelectResult} data={searchResult} />
<Layout.Vertical className={cx(css.preview, { [css.noResult]: !searchResult?.length })}>
<Container className={css.filePath}>
<Container>
<Layout.Horizontal spacing="small">
<Link
className={css.pathText}
to={routes.toCODERepository({
repoPath: (repoMetadata?.path as string) || '',
gitRef
})}>
<Icon name={CodeIcon.Folder} />
</Link>
<Text inline className={css.pathText}>
/
</Text>
<Breadcrumbs
items={breadcrumbs}
breadcrumbRenderer={({ text, href }: IBreadcrumbProps) => {
return (
<Link to={href as string}>
<Text inline className={css.pathText}>
{text}
</Text>
</Link>
)
}}
/>
</Layout.Horizontal>
</Container>
<Button
variation={ButtonVariation.SECONDARY}
text={getString('viewFile')}
size={ButtonSize.SMALL}
rightIcon="chevron-right"
onClick={() => {
history.push(
routes.toCODERepository({
repoPath: (repoMetadata?.path as string) || '',
gitRef,
resourcePath
})
)
}}
/>
</Container>
<Container className={css.fileContent}>
<Editor
viewRef={viewRef}
filename={filename}
content={fileContent}
readonly={true}
onViewUpdate={onViewUpdate}
extensions={extensions}
maxHeight="auto"
/>
</Container>
</Layout.Vertical>
</Split>
</Truthy>
<Falsy>
<NoResultCard
showWhen={() => true}
forSearch={true}
title={getString('startSearching')}
emptySearchMessage={getString('poweredByAI')}
/>
</Falsy>
</Match>
</PageBody>
</Container>
)
}
interface SearchResultsProps {
data: SearchResultType[]
onSelect: (fileName: string, filePath: string, content: string, highlightedLines: number[]) => void
}
const SearchResults: React.FC<SearchResultsProps> = ({ data, onSelect }) => {
const { getString } = useStrings()
const [selected, setSelected] = useState(data?.[0]?.file_path || '')
const count = useMemo(() => data?.length || 0, [data])
useEffect(() => {
if (data?.length) {
const item = data[0]
onSelect(
item.file_name,
item.file_path,
(item.content || []).join('\n').replace(/^\n/g, '').trim(),
range(item.start_line, item.end_line)
)
}
}, [data, onSelect])
return (
<Container className={css.searchResult}>
<Layout.Vertical spacing="medium">
<Text className={css.resultTitle}>
{stringSubstitute(getString('searchResult'), { count: count - 4 ? `(${count})` : ' ' })}
</Text>
{data.map(item => (
<Container
key={item.file_path}
className={cx(css.result, { [css.selected]: item.file_path === selected })}
{...ButtonRoleProps}
onClick={() => {
setSelected(item.file_path)
onSelect(
item.file_name,
item.file_path,
(item.content || []).join('\n').replace(/^\n/g, '').trim(),
range(item.start_line, item.end_line)
)
}}>
<Layout.Vertical spacing="small">
<Container>
<Layout.Horizontal className={css.layout}>
<Container className={css.texts}>
<Layout.Vertical spacing="xsmall">
<Text className={css.filename} lineClamp={1}>
{item.file_name}
</Text>
<Text className={css.path} lineClamp={1}>
{item.file_path}
</Text>
</Layout.Vertical>
</Container>
<Text inline className={css.aiLabel}>
{getString('aiSearch')}
</Text>
</Layout.Horizontal>
</Container>
<Editor
filename={item.file_name}
content={(item.content || []).join('\n').replace(/^\n/g, '').trim()}
readonly={true}
maxHeight="200px"
darkTheme
/>
</Layout.Vertical>
</Container>
))}
</Layout.Vertical>
</Container>
)
}
type SearchResultType = {
commit: string
file_path: string
start_line: number
end_line: number
file_name: string
content: string[]
}
const range = (start: number, stop: number, step = 1) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step)

View File

@ -0,0 +1,66 @@
import { ViewPlugin, ViewUpdate, type EditorView, Decoration } from '@codemirror/view'
import { RangeSetBuilder, Compartment, Extension } from '@codemirror/state'
export const addClassToLinesExtension: AddClassToLinesExtension = (lines = [], className) => {
const highlightLineDecoration = Decoration.line({
attributes: { class: className }
})
function updateDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>()
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = view.state.doc.lineAt(pos)
if (lines.includes(line.number)) {
builder.add(line.from, line.from, highlightLineDecoration)
}
pos = line.to + 1
}
}
return builder.finish()
}
class Plugin {
decorations = Decoration.none
constructor(view: EditorView) {
this.decorations = updateDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = updateDecorations(update.view)
}
}
}
const config = new Compartment()
const update: AddClassToLinesReturnType[1] = (_lines, view) => {
lines = _lines
view?.dispatch({
effects: config.reconfigure(
ViewPlugin.fromClass(Plugin, {
decorations: v => v.decorations
})
)
})
}
return [
config.of(
ViewPlugin.fromClass(Plugin, {
decorations: v => v.decorations
})
),
update
]
}
type AddClassToLinesReturnType = [Extension, (lines: number[], view?: EditorView) => void]
type AddClassToLinesExtension = (lines: number[], className: string) => AddClassToLinesReturnType