feat: [CODE-1143] Keyword Search UI (#884)

This commit is contained in:
rajarshee.chatterjee@harness.io 2023-12-12 17:36:59 +00:00 committed by Harness
parent b9abcdee89
commit f6c228dd75
27 changed files with 1474 additions and 309 deletions

View File

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

View File

@ -98,7 +98,9 @@ export interface CODERoutes {
toCODESettings: ( toCODESettings: (
args: RequiredField<Pick<CODEProps, 'repoPath' | 'settingSection' | 'ruleId' | 'settingSectionMode'>, 'repoPath'> args: RequiredField<Pick<CODEProps, 'repoPath' | 'settingSection' | 'ruleId' | 'settingSectionMode'>, 'repoPath'>
) => string ) => string
toCODESearch: (args: Required<Pick<CODEProps, 'repoPath'>>) => string toCODEProjectSearch: (args: Required<Pick<CODEProps, 'space'>>) => string
toCODERepositorySearch: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODESemanticSearch: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEExecutions: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline'>>) => string toCODEExecutions: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline'>>) => string
toCODEExecution: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline' | 'execution'>>) => string toCODEExecution: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline' | 'execution'>>) => string
toCODESecret: (args: Required<Pick<CODEProps, 'space' | 'secret'>>) => string toCODESecret: (args: Required<Pick<CODEProps, 'space' | 'secret'>>) => string
@ -155,7 +157,9 @@ export const routes: CODERoutes = {
`/${repoPath}/settings${settingSection ? '/' + settingSection : ''}${ruleId ? '/' + ruleId : ''}${ `/${repoPath}/settings${settingSection ? '/' + settingSection : ''}${ruleId ? '/' + ruleId : ''}${
settingSectionMode ? '/' + settingSectionMode : '' settingSectionMode ? '/' + settingSectionMode : ''
}`, }`,
toCODESearch: ({ repoPath }) => `/${repoPath}/search`, toCODEProjectSearch: ({ space }) => `/${space}/search`,
toCODERepositorySearch: ({ repoPath }) => `/${repoPath}/search`,
toCODESemanticSearch: ({ repoPath }) => `/${repoPath}/search/semantic`,
toCODEWebhooks: ({ repoPath }) => `/${repoPath}/webhooks`, toCODEWebhooks: ({ repoPath }) => `/${repoPath}/webhooks`,
toCODEWebhookNew: ({ repoPath }) => `/${repoPath}/webhooks/new`, toCODEWebhookNew: ({ repoPath }) => `/${repoPath}/webhooks/new`,
toCODEWebhookDetails: ({ repoPath, webhookId }) => `/${repoPath}/webhook/${webhookId}`, toCODEWebhookDetails: ({ repoPath, webhookId }) => `/${repoPath}/webhook/${webhookId}`,

View File

@ -47,6 +47,7 @@ import ExecutionList from 'pages/ExecutionList/ExecutionList'
import Execution from 'pages/Execution/Execution' import Execution from 'pages/Execution/Execution'
import Secret from 'pages/Secret/Secret' import Secret from 'pages/Secret/Secret'
import Search from 'pages/Search/Search' import Search from 'pages/Search/Search'
import KeywordSearch from 'pages/Search/KeywordSearch'
import AddUpdatePipeline from 'pages/AddUpdatePipeline/AddUpdatePipeline' import AddUpdatePipeline from 'pages/AddUpdatePipeline/AddUpdatePipeline'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import PipelineSettings from 'components/PipelineSettings/PipelineSettings' import PipelineSettings from 'components/PipelineSettings/PipelineSettings'
@ -292,7 +293,15 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</LayoutWithSideNav> </LayoutWithSideNav>
</Route> </Route>
<Route path={routes.toCODESearch({ repoPath })} exact> <Route
path={[routes.toCODEProjectSearch({ space: pathProps.space }), routes.toCODERepositorySearch({ repoPath })]}
exact>
<LayoutWithSideNav title={'Search'}>
<KeywordSearch />
</LayoutWithSideNav>
</Route>
<Route path={routes.toCODESemanticSearch({ repoPath })} exact>
<LayoutWithSideNav title={getString('pageTitle.search')}> <LayoutWithSideNav title={getString('pageTitle.search')}>
<Search /> <Search />
</LayoutWithSideNav> </LayoutWithSideNav>

View File

@ -0,0 +1,80 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.searchModal {
--modal-width: min(1254px, 80vw);
width: var(--modal-width);
padding: var(--spacing-medium) var(--spacing-xlarge) var(--spacing-xlarge);
> span:first-of-type {
display: none;
}
.layout {
width: 100%;
.searchContainer {
position: relative;
width: 100%;
}
}
.sectionHeader {
font-size: 10px !important;
font-weight: 600 !important;
letter-spacing: 0.237px;
color: var(--grey-300) !important;
text-transform: uppercase;
}
.sampleQuery {
cursor: pointer;
border-radius: 4px;
&:hover,
&.selected {
background-color: var(--grey-100);
}
}
}
.searchBox {
cursor: pointer;
input,
input:focus {
border: 1px solid var(--grey-200) !important;
pointer-events: none;
user-select: none;
}
input {
width: 350px !important;
}
}
.backdrop {
background-color: rgb(16 22 26 / 25%);
}
.portal {
:global {
.bp3-dialog-container.bp3-overlay-content {
align-items: flex-start !important;
}
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
// This is an auto-generated file
export declare const backdrop: string
export declare const layout: string
export declare const portal: string
export declare const sampleQuery: string
export declare const searchBox: string
export declare const searchContainer: string
export declare const searchModal: string
export declare const sectionHeader: string
export declare const selected: string

View File

@ -0,0 +1,178 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState } from 'react'
import { Container, Dialog, Layout, Text, Utils } from '@harnessio/uicore'
import { Color } from '@harnessio/design-system'
import { Filter } from 'iconoir-react'
import { useHistory } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { noop } from 'lodash-es'
import cx from 'classnames'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { ButtonRoleProps } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import KeywordSearchbar from 'components/KeywordSearchbar/KeywordSearchbar'
import css from './KeywordSearch.module.scss'
interface KeywordSearchProps {
repoMetadata?: GitInfoProps['repoMetadata']
}
const KeywordSearch = ({ repoMetadata }: KeywordSearchProps) => {
const { getString } = useStrings()
const { routes } = useAppContext()
const space = useGetSpaceParam()
const history = useHistory()
const [search, setSearch] = useState('')
const [showSearchModal, setShowSearchModal] = useState(false)
const [searchSampleQueryIndex, setSearchSampleQueryIndex] = useState<number>(0)
const performSearch = useCallback(
(q: string) => {
if (repoMetadata?.path) {
history.push({
pathname: routes.toCODERepositorySearch({
repoPath: repoMetadata.path as string
}),
search: `q=${q}`
})
} else {
history.push({
pathname: routes.toCODEProjectSearch({
space
}),
search: `q=${q}`
})
}
},
[history, repoMetadata?.path, routes]
)
const onSearch = useCallback(() => {
if (search?.trim()) {
performSearch(search)
}
}, [performSearch, search])
useHotkeys(
'ctrl+k',
() => {
if (!showSearchModal) {
setShowSearchModal(true)
}
},
[showSearchModal]
)
return (
<Container
className={css.searchBox}
{...ButtonRoleProps}
onClick={() => {
setShowSearchModal(true)
}}>
<SearchInputWithSpinner readOnly placeholder={getString('codeSearch') + ` (ctrl-k)`} query={''} setQuery={noop} />
{showSearchModal && (
<Container onClick={Utils.stopEvent}>
<Dialog
className={css.searchModal}
backdropClassName={css.backdrop}
portalClassName={css.portal}
isOpen={true}
enforceFocus={false}
onClose={() => {
setShowSearchModal(false)
}}>
<Container>
<Layout.Vertical spacing="large">
<Container>
<Layout.Horizontal className={css.layout}>
<Container className={css.searchContainer}>
<KeywordSearchbar
value={search}
onChange={setSearch}
onSearch={onSearch}
onKeyDown={e => {
if (!search?.trim()) {
switch (e.key) {
case 'ArrowDown':
setSearchSampleQueryIndex(index => {
return index + 1 > searchSampleQueries.length ? 1 : index + 1
})
break
case 'ArrowUp':
setSearchSampleQueryIndex(index => {
return index - 1 > 0 ? index - 1 : searchSampleQueries.length
})
break
}
}
}}
/>
</Container>
</Layout.Horizontal>
</Container>
<Text className={css.sectionHeader}>{getString('searchExamples')}</Text>
<Container>
{searchSampleQueries.map(sampleQuery => {
const { keyword, description } = sampleQuery
return (
<Layout.Horizontal
key={keyword}
className={cx(css.sampleQuery, {
[css.selected]: searchSampleQueries.indexOf(sampleQuery) === searchSampleQueryIndex - 1
})}
padding={'small'}
spacing={'xsmall'}
flex={{ alignItems: 'center', justifyContent: 'flex-start' }}
onClick={() => setSearch(keyword)}>
<Filter />
<Text color={Color.PRIMARY_7} font={{ weight: 'semi-bold' }}>
{keyword}
</Text>
<Text color={Color.GREY_600}>{description}</Text>
</Layout.Horizontal>
)
})}
</Container>
</Layout.Vertical>
</Container>
</Dialog>
</Container>
)}
</Container>
)
}
export default KeywordSearch
const searchSampleQueries = [
{ keyword: `class`, description: 'Search for class' },
{ keyword: `class file:^cmd`, description: 'Search for class in files starting with cmd' },
{
keyword: `class or printf`,
description: 'Include only results from file paths matching the given search pattern'
},
{ keyword: 'initial commit', description: 'Search for exact phrase initial commit' }
]

View File

@ -0,0 +1,142 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.searchBox {
input,
input:focus {
border: 1px solid var(--ai-purple-600) !important;
}
svg path {
fill: var(--ai-purple-600) !important;
}
img {
position: absolute;
top: 5px;
right: 6px;
}
}
.searchModal {
--modal-width: min(970px, 80vw);
--input-width: calc(var(--modal-width) - 154px);
width: var(--modal-width);
padding: var(--spacing-medium) var(--spacing-xxlarge) var(--spacing-xlarge);
> span:first-of-type {
display: none;
}
.layout {
width: 100%;
.searchContainer {
position: relative;
span[icon] {
display: none;
}
.searchIcon {
position: absolute;
left: 12px;
top: 11px;
z-index: 1;
}
img {
position: absolute;
right: 14px;
top: 6px;
}
input {
padding-left: 35px !important;
font-size: 14px;
font-weight: 500;
line-height: 19px;
letter-spacing: 0.23749999701976776px;
border-color: var(--ai-purple-600);
color: var(--grey-500);
width: var(--input-width) !important;
}
}
button {
--button-height: 40px !important;
}
}
.sectionHeader {
font-size: 13px !important;
font-weight: 500 !important;
letter-spacing: 0.23749999701976776px;
color: var(--grey-500) !important;
text-transform: uppercase;
}
.sampleQuery {
height: 44px;
background-color: var(--grey-50);
color: var(--grey-500) !important;
border-radius: 4px;
padding-left: 32px;
position: relative;
font-size: 14px !important;
font-weight: 500 !important;
line-height: 19px !important;
letter-spacing: 0.23749999701976776px;
display: flex;
align-items: center;
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22none%22%20viewBox%3D%220%200%2017%2017%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m9.91699%208.50004h3.54171c.1878%200%20.368.07463.5008.20747.1329.13283.2075.313.2075.50086v2.47913c0%20.1879-.0746.3681-.2075.5009-.1328.1328-.313.2075-.5008.2075h-2.8334c-.1878%200-.368-.0747-.5008-.2075-.13288-.1328-.20751-.313-.20751-.5009zm0%200c0-1.77083.70831-2.83333%202.83331-3.89583m-9.91664%203.89583h3.54167c.18786%200%20.36802.07463.50086.20747.13284.13283.20747.313.20747.50086v2.47913c0%20.1879-.07463.3681-.20747.5009s-.313.2075-.50086.2075h-2.83334c-.18786%200-.36803-.0747-.50087-.2075-.13283-.1328-.20746-.313-.20746-.5009zm0%200c0-1.77083.70833-2.83333%202.83333-3.89583%22%20stroke%3D%22%23dad0f6%22%20stroke-linecap%3D%22round%22%2F%3E%3Cg%20fill%3D%22%23dad0f6%22%3E%3Cpath%20d%3D%22m10%209h4v3h-4z%22%2F%3E%3Cpath%20d%3D%22m3%209h4v3h-4z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-size: 16px;
background-position: left 12px top 7px;
&:hover,
&.selected {
background-color: var(--grey-100);
}
&.selected svg {
visibility: visible;
}
svg {
position: absolute;
top: 14px;
right: 15px;
color: var(--grey-300);
visibility: hidden;
}
}
}
.backdrop {
background-color: rgb(16 22 26 / 25%);
}
.portal {
:global {
.bp3-dialog-container.bp3-overlay-content {
align-items: flex-start !important;
}
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
// This is an auto-generated file
export declare const backdrop: string
export declare const layout: string
export declare const portal: string
export declare const sampleQuery: string
export declare const searchBox: string
export declare const searchContainer: string
export declare const searchIcon: string
export declare const searchModal: string
export declare const sectionHeader: string
export declare const selected: string

View File

@ -0,0 +1,172 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState } from 'react'
import { Button, ButtonSize, ButtonVariation, Container, Dialog, Layout, Text, Utils } from '@harnessio/uicore'
import { LongArrowDownLeft, Search } from 'iconoir-react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useHistory } from 'react-router-dom'
import { noop } from 'lodash-es'
import cx from 'classnames'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { ButtonRoleProps } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import svg from './search-background.svg'
import css from './SemanticSearch.module.scss'
const SemanticSearch = ({ repoMetadata }: Pick<GitInfoProps, 'repoMetadata'>) => {
const { getString } = useStrings()
const { routes } = useAppContext()
const history = useHistory()
const [search, setSearch] = useState('')
const [searchSampleQueryIndex, setSearchSampleQueryIndex] = useState<number>(0)
const [showSearchModal, setShowSearchModal] = useState(false)
const performSearch = useCallback(
(q: string) => {
history.push({
pathname: routes.toCODESemanticSearch({
repoPath: repoMetadata.path as string
}),
search: `q=${q}`
})
},
[history, repoMetadata.path, routes]
)
const onSearch = useCallback(() => {
if (search?.trim()) {
performSearch(search)
} else if (searchSampleQueryIndex > 0 && searchSampleQueryIndex <= searchSampleQueries.length) {
performSearch(searchSampleQueries[searchSampleQueryIndex - 1])
}
}, [performSearch, search, searchSampleQueryIndex])
useHotkeys(
'ctrl+k',
() => {
if (!showSearchModal) {
setShowSearchModal(true)
}
},
[showSearchModal]
)
return (
<Container
className={css.searchBox}
{...ButtonRoleProps}
onClick={() => {
setShowSearchModal(true)
}}>
<SearchInputWithSpinner readOnly placeholder={getString('codeSearch') + ` (ctrl-k)`} query={''} setQuery={noop} />
{<img src={svg} width={95} height={22} />}
{showSearchModal && (
<Container onClick={Utils.stopEvent}>
<Dialog
className={css.searchModal}
backdropClassName={css.backdrop}
portalClassName={css.portal}
isOpen={true}
enforceFocus={false}
onClose={() => {
setShowSearchModal(false)
}}>
<Container>
<Layout.Vertical spacing="large">
<Container>
<Layout.Horizontal className={css.layout}>
<Container className={css.searchContainer}>
<Search className={css.searchIcon} width={18} height={18} color="var(--ai-purple-600)" />
<SearchInputWithSpinner
placeholder={getString('codeSearchModal')}
spinnerPosition="right"
query={search}
type="text"
setQuery={q => {
if (q?.trim()) {
setSearchSampleQueryIndex(0)
}
setSearch(q)
}}
height={40}
onSearch={onSearch}
onKeyDown={e => {
if (!search?.trim()) {
switch (e.key) {
case 'ArrowDown':
setSearchSampleQueryIndex(index => {
return index + 1 > searchSampleQueries.length ? 1 : index + 1
})
break
case 'ArrowUp':
setSearchSampleQueryIndex(index => {
return index - 1 > 0 ? index - 1 : searchSampleQueries.length
})
break
}
}
}}
/>
{!search && <img src={svg} width={132} height={28} />}
</Container>
<Button
variation={ButtonVariation.AI}
text={getString('search')}
size={ButtonSize.MEDIUM}
onClick={onSearch}
/>
</Layout.Horizontal>
</Container>
<Text className={css.sectionHeader}>{getString('searchHeader')}</Text>
<Container>
<Layout.Vertical spacing="medium">
{searchSampleQueries.map((sampleQuery, index) => (
<Text
key={index}
className={cx(css.sampleQuery, { [css.selected]: index === searchSampleQueryIndex - 1 })}
{...ButtonRoleProps}
onClick={() => {
performSearch(sampleQuery)
}}>
{sampleQuery}
<LongArrowDownLeft color="" />
</Text>
))}
</Layout.Vertical>
</Container>
</Layout.Vertical>
</Container>
</Dialog>
</Container>
)}
</Container>
)
}
export default SemanticSearch
// These sample queries are in English only - No need to do i18n for them
const searchSampleQueries = [
`Where is the code that handles authentication?`,
`Where is the application entry point?`,
`Where do we configure the logger?`
]

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1,54 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.searchCtn {
position: relative;
width: 100%;
input {
background-color: transparent !important;
color: transparent !important;
caret-color: var(--black);
}
> div:nth-child(2),
> div:nth-child(2) > div,
> div:nth-child(2) > div > div {
width: 100%;
}
> div:nth-child(2) > div > div {
padding-left: 0 !important;
}
.textCtn {
position: absolute;
left: 31px;
top: 8px;
color: black;
font-size: 13px;
}
.highltedText {
background-color: var(--primary-1);
border-radius: 4px;
color: var(--primary-7);
}
.andOr {
color: var(--red-700);
}
}

View File

@ -0,0 +1,22 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable */
// This is an auto-generated file
export declare const andOr: string
export declare const highltedText: string
export declare const searchCtn: string
export declare const textCtn: string

View File

@ -0,0 +1,80 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import css from './KeywordSearchbar.module.scss'
interface KeywordSearchbarProps {
value: string
onChange: (value: string) => void
onSearch?: (searchTerm: string) => void
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void
}
const KEYWORD_REGEX = /((?:(?:-{0,1})(?:repo|lang|file|case|count)):\S*|(?: or|and ))/gi
const KeywordSearchbar: FC<KeywordSearchbarProps> = ({ value, onChange, onSearch, onKeyDown }) => {
return (
<div className={css.searchCtn}>
<div className={css.textCtn}>
{value.split(KEYWORD_REGEX).map(text => {
const isMatch = text.match(KEYWORD_REGEX)
if (text.match(/ or|and /gi)) {
return (
<span key={text} className={css.andOr}>
{text}
</span>
)
}
const separatorIdx = text.indexOf(':')
const [keyWord, keyVal] = [text.slice(0, separatorIdx), text.slice(separatorIdx + 1)]
if (isMatch) {
if (keyWord.match(/ or|and /gi)) {
return (
<span key={keyWord} className={css.andOr}>
{keyWord}
</span>
)
}
return (
<>
{keyWord}:<span className={css.highltedText}>{keyVal}</span>
</>
)
} else {
return text
}
})}
</div>
<SearchInputWithSpinner
width={'100%' as any}
query={value}
setQuery={onChange}
onSearch={onSearch}
onKeyDown={onKeyDown}
/>
</div>
)
}
export default KeywordSearchbar

View File

@ -132,6 +132,7 @@ export interface StringsMap {
checkRuns: string checkRuns: string
checkSuites: string checkSuites: string
checks: string checks: string
clear: string
clickHereToDownload: string clickHereToDownload: string
clone: string clone: string
cloneHTTPS: string cloneHTTPS: string
@ -389,6 +390,7 @@ export interface StringsMap {
isRequired: string isRequired: string
key: string key: string
killed: string killed: string
language: string
leaveAComment: string leaveAComment: string
license: string license: string
lineBreaks: string lineBreaks: string
@ -407,6 +409,7 @@ export interface StringsMap {
missingPermsContent: string missingPermsContent: string
myComments: string myComments: string
nDays: string nDays: string
nMoreMatches: string
name: string name: string
nameYourBranch: string nameYourBranch: string
nameYourFile: string nameYourFile: string
@ -690,6 +693,7 @@ export interface StringsMap {
resolve: string resolve: string
resolved: string resolved: string
resolvedComments: string resolvedComments: string
results: string
reviewerNotFound: string reviewerNotFound: string
reviewers: string reviewers: string
role: string role: string
@ -701,6 +705,7 @@ export interface StringsMap {
scrollToTop: string scrollToTop: string
search: string search: string
searchBranches: string searchBranches: string
searchExamples: string
searchHeader: string searchHeader: string
searchResult: string searchResult: string
secret: string secret: string
@ -721,8 +726,11 @@ export interface StringsMap {
'secrets.secretUpdated': string 'secrets.secretUpdated': string
'secrets.showValue': string 'secrets.showValue': string
'secrets.updateSecret': string 'secrets.updateSecret': string
seeNMoreMatches: string
selectBranchPlaceHolder: string selectBranchPlaceHolder: string
selectLanguagePlaceholder: string
selectRange: string selectRange: string
selectRepositoryPlaceholder: string
selectSpace: string selectSpace: string
selectSpaceText: string selectSpaceText: string
selectStatuses: string selectStatuses: string
@ -734,7 +742,9 @@ export interface StringsMap {
showCommitHistory: string showCommitHistory: string
showEverything: string showEverything: string
showLess: string showLess: string
showLessMatches: string
showMore: string showMore: string
showNMoreMatches: string
signIn: string signIn: string
signUp: string signUp: string
skipped: string skipped: string

View File

@ -943,3 +943,13 @@ pipelineConfig:
label: Pipeline Configuration label: Pipeline Configuration
yamlUpdated: It looks like the YAML got modified. Refresh changes? yamlUpdated: It looks like the YAML got modified. Refresh changes?
discard: Discard discard: Discard
language: Language
selectLanguagePlaceholder: '- Select language here -'
selectRepositoryPlaceholder: '- Select repository here -'
results: Results
showNMoreMatches: Show {{ n }} more matches
showLessMatches: Show less
clear: Clear
searchExamples: Search Examples
nMoreMatches: This file contains {{ n }} more matches not shown.
seeNMoreMatches: See all {{ n }} matches in the full file

View File

@ -50,6 +50,8 @@ export const DefaultMenu: React.FC = () => {
return !isGitRev(ref) ? ref : '' return !isGitRev(ref) ? ref : ''
}, [commitRef, gitRef]) }, [commitRef, gitRef])
const isSemanticSearchEnabled = false
return ( return (
<Container className={css.main}> <Container className={css.main}>
<Layout.Vertical spacing="small"> <Layout.Vertical spacing="small">
@ -159,15 +161,26 @@ export const DefaultMenu: React.FC = () => {
data-code-repo-section="search" data-code-repo-section="search"
isSubLink isSubLink
label={getString('search')} label={getString('search')}
to={routes.toCODESearch({ to={
repoPath isSemanticSearchEnabled
})} ? routes.toCODESemanticSearch({ repoPath })
: `${routes.toCODERepositorySearch({ repoPath })}?q=repo:${repoPath}`
}
/> />
)} )}
</Layout.Vertical> </Layout.Vertical>
</Container> </Container>
</Render> </Render>
<Render when={selectedSpace}>
<NavMenuItem
icon="thinner-search"
data-code-repo-section="search"
label={getString('search')}
to={routes.toCODEProjectSearch({ space: selectedSpace?.path as string })}
/>
</Render>
{standalone && ( {standalone && (
<Render when={selectedSpace}> <Render when={selectedSpace}>
<NavMenuItem <NavMenuItem

View File

@ -47,6 +47,7 @@ import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { NoResultCard } from 'components/NoResultCard/NoResultCard' import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { ResourceListingPagination } from 'components/ResourceListingPagination/ResourceListingPagination' import { ResourceListingPagination } from 'components/ResourceListingPagination/ResourceListingPagination'
import { RepoPublicLabel } from 'components/RepoPublicLabel/RepoPublicLabel' import { RepoPublicLabel } from 'components/RepoPublicLabel/RepoPublicLabel'
import KeywordSearch from 'components/CodeSearch/KeywordSearch'
import noRepoImage from './no-repo.svg' import noRepoImage from './no-repo.svg'
import FeatureMap from './FeatureMap/FeatureMap' import FeatureMap from './FeatureMap/FeatureMap'
import css from './RepositoriesListing.module.scss' import css from './RepositoriesListing.module.scss'
@ -62,7 +63,7 @@ export default function RepositoriesListing() {
const [nameTextWidth, setNameTextWidth] = useState(600) const [nameTextWidth, setNameTextWidth] = useState(600)
const space = useGetSpaceParam() const space = useGetSpaceParam()
const [searchTerm, setSearchTerm] = useState<string | undefined>() const [searchTerm, setSearchTerm] = useState<string | undefined>()
const { routes } = useAppContext() const { routes, standalone } = useAppContext()
const { updateQueryParams } = useUpdateQueryParams() const { updateQueryParams } = useUpdateQueryParams()
const pageBrowser = useQueryParams<PageBrowserProps>() const pageBrowser = useQueryParams<PageBrowserProps>()
const pageInit = pageBrowser.page ? parseInt(pageBrowser.page) : 1 const pageInit = pageBrowser.page ? parseInt(pageBrowser.page) : 1
@ -197,7 +198,7 @@ export default function RepositoriesListing() {
return ( return (
<Container className={css.main}> <Container className={css.main}>
<PageHeader title={getString('repositories')} /> <PageHeader title={getString('repositories')} toolbar={standalone ? null : <KeywordSearch />} />
<PageBody <PageBody
className={cx({ [css.withError]: !!error })} className={cx({ [css.withError]: !!error })}
error={error ? getErrorMessage(error) : null} error={error ? getErrorMessage(error) : null}

View File

@ -70,8 +70,9 @@
background: var(--grey-100); background: var(--grey-100);
} }
} }
}
.searchBox { .searchBoxCtn {
position: absolute; position: absolute;
right: 16px; right: 16px;
top: -50px; top: -50px;
@ -82,7 +83,7 @@
input, input,
input:focus { input:focus {
border: 1px solid var(--ai-purple-600) !important; border: 1px solid var(--grey-200) !important;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
@ -91,126 +92,11 @@
width: 350px !important; width: 350px !important;
} }
svg path {
fill: var(--ai-purple-600) !important;
}
img { img {
position: absolute; position: absolute;
top: 5px; top: 5px;
right: 6px; right: 6px;
} }
}
}
.searchModal {
--modal-width: min(970px, 80vw);
--input-width: calc(var(--modal-width) - 154px);
width: var(--modal-width);
padding: var(--spacing-medium) var(--spacing-xxlarge) var(--spacing-xlarge);
> span:first-of-type {
display: none;
}
.layout {
width: 100%;
.searchContainer {
position: relative;
span[icon] {
display: none;
}
.searchIcon {
position: absolute;
left: 12px;
top: 11px;
z-index: 1;
}
img {
position: absolute;
right: 14px;
top: 6px;
}
input {
padding-left: 35px !important;
font-size: 14px;
font-weight: 500;
line-height: 19px;
letter-spacing: 0.23749999701976776px;
border-color: var(--ai-purple-600);
color: var(--grey-500);
width: var(--input-width) !important;
}
}
button {
--button-height: 40px !important;
}
}
.sectionHeader {
font-size: 13px !important;
font-weight: 500 !important;
letter-spacing: 0.23749999701976776px;
color: var(--grey-500) !important;
text-transform: uppercase;
}
.sampleQuery {
height: 44px;
background-color: var(--grey-50);
color: var(--grey-500) !important;
border-radius: 4px;
padding-left: 32px;
position: relative;
font-size: 14px !important;
font-weight: 500 !important;
line-height: 19px !important;
letter-spacing: 0.23749999701976776px;
display: flex;
align-items: center;
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22none%22%20viewBox%3D%220%200%2017%2017%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m9.91699%208.50004h3.54171c.1878%200%20.368.07463.5008.20747.1329.13283.2075.313.2075.50086v2.47913c0%20.1879-.0746.3681-.2075.5009-.1328.1328-.313.2075-.5008.2075h-2.8334c-.1878%200-.368-.0747-.5008-.2075-.13288-.1328-.20751-.313-.20751-.5009zm0%200c0-1.77083.70831-2.83333%202.83331-3.89583m-9.91664%203.89583h3.54167c.18786%200%20.36802.07463.50086.20747.13284.13283.20747.313.20747.50086v2.47913c0%20.1879-.07463.3681-.20747.5009s-.313.2075-.50086.2075h-2.83334c-.18786%200-.36803-.0747-.50087-.2075-.13283-.1328-.20746-.313-.20746-.5009zm0%200c0-1.77083.70833-2.83333%202.83333-3.89583%22%20stroke%3D%22%23dad0f6%22%20stroke-linecap%3D%22round%22%2F%3E%3Cg%20fill%3D%22%23dad0f6%22%3E%3Cpath%20d%3D%22m10%209h4v3h-4z%22%2F%3E%3Cpath%20d%3D%22m3%209h4v3h-4z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-size: 16px;
background-position: left 12px top 7px;
&:hover,
&.selected {
background-color: var(--grey-100);
}
&.selected svg {
visibility: visible;
}
svg {
position: absolute;
top: 14px;
right: 15px;
color: var(--grey-300);
visibility: hidden;
}
}
}
.backdrop {
background-color: rgb(16 22 26 / 25%);
}
.portal {
:global {
.bp3-dialog-container.bp3-overlay-content {
align-items: flex-start !important;
}
}
} }
.mainBorder { .mainBorder {

View File

@ -16,19 +16,10 @@
/* eslint-disable */ /* eslint-disable */
// This is an auto-generated file // This is an auto-generated file
export declare const backdrop: string
export declare const btnColorFix: string export declare const btnColorFix: string
export declare const layout: string
export declare const main: string export declare const main: string
export declare const mainBorder: string export declare const mainBorder: string
export declare const mainContainer: string export declare const mainContainer: string
export declare const portal: string
export declare const refRoot: string export declare const refRoot: string
export declare const rootSlash: string export declare const rootSlash: string
export declare const sampleQuery: string export declare const searchBoxCtn: string
export declare const searchBox: string
export declare const searchContainer: string
export declare const searchIcon: string
export declare const searchModal: string
export declare const sectionHeader: string
export declare const selected: string

View File

@ -14,23 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import React, { useCallback, useMemo, useState } from 'react' import React, { useMemo } from 'react'
import { noop } from 'lodash-es' import { Container, Layout, Button, FlexExpander, ButtonVariation, Text } from '@harnessio/uicore'
import {
Container,
Layout,
Button,
ButtonSize,
FlexExpander,
ButtonVariation,
Text,
Utils,
Dialog
} from '@harnessio/uicore'
import cx from 'classnames' import cx from 'classnames'
import { Icon } from '@harnessio/icons' import { Icon } from '@harnessio/icons'
import { useHotkeys } from 'react-hotkeys-hook'
import { LongArrowDownLeft, Search } from 'iconoir-react'
import { Color } from '@harnessio/design-system' import { Color } from '@harnessio/design-system'
import { Breadcrumbs, IBreadcrumbProps } from '@blueprintjs/core' import { Breadcrumbs, IBreadcrumbProps } from '@blueprintjs/core'
import { Link, useHistory } from 'react-router-dom' import { Link, useHistory } from 'react-router-dom'
@ -40,10 +27,9 @@ import { CloneButtonTooltip } from 'components/CloneButtonTooltip/CloneButtonToo
import { CodeIcon, GitInfoProps, isDir, isGitRev, isRefATag } from 'utils/GitUtils' import { CodeIcon, GitInfoProps, isDir, isGitRev, isRefATag } from 'utils/GitUtils'
import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect' import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import { useCreateBranchModal } from 'components/CreateBranchModal/CreateBranchModal' import { useCreateBranchModal } from 'components/CreateBranchModal/CreateBranchModal'
import KeywordSearch from 'components/CodeSearch/KeywordSearch'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam' import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { ButtonRoleProps, permissionProps } from 'utils/Utils' import { permissionProps } from 'utils/Utils'
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
import svg from './search-background.svg'
import css from './ContentHeader.module.scss' import css from './ContentHeader.module.scss'
export function ContentHeader({ export function ContentHeader({
@ -57,37 +43,6 @@ export function ContentHeader({
const history = useHistory() const history = useHistory()
const _isDir = isDir(resourceContent) const _isDir = isDir(resourceContent)
const space = useGetSpaceParam() const space = useGetSpaceParam()
const [showSearchModal, setShowSearchModal] = useState(false)
const [searchSampleQueryIndex, setSearchSampleQueryIndex] = useState<number>(0)
const [search, setSearch] = useState('')
const performSearch = useCallback(
(q: string) => {
history.push({
pathname: routes.toCODESearch({
repoPath: repoMetadata.path as string
}),
search: `q=${q}`
})
},
[history, repoMetadata.path, routes]
)
const onSearch = useCallback(() => {
if (search?.trim()) {
performSearch(search)
} else if (searchSampleQueryIndex > 0 && searchSampleQueryIndex <= searchSampleQueries.length) {
performSearch(searchSampleQueries[searchSampleQueryIndex - 1])
}
}, [performSearch, search, searchSampleQueryIndex])
useHotkeys(
'ctrl+k',
() => {
if (!showSearchModal) {
setShowSearchModal(true)
}
},
[showSearchModal]
)
const permPushResult = hooks?.usePermissionTranslate?.( const permPushResult = hooks?.usePermissionTranslate?.(
{ {
@ -200,109 +155,7 @@ export function ContentHeader({
</> </>
)} )}
</Layout.Horizontal> </Layout.Horizontal>
{!standalone && false && ( <div className={css.searchBoxCtn}>{!standalone ? <KeywordSearch repoMetadata={repoMetadata} /> : null}</div>
<Container
className={css.searchBox}
{...ButtonRoleProps}
onClick={() => {
setShowSearchModal(true)
}}>
<SearchInputWithSpinner
readOnly
placeholder={getString('codeSearch') + ` (ctrl-k)`}
query={''}
setQuery={noop}
/>
{<img src={svg} width={95} height={22} />}
{showSearchModal && (
<Container onClick={Utils.stopEvent}>
<Dialog
className={css.searchModal}
backdropClassName={css.backdrop}
portalClassName={css.portal}
isOpen={true}
enforceFocus={false}
onClose={() => {
setShowSearchModal(false)
}}>
<Container>
<Layout.Vertical spacing="large">
<Container>
<Layout.Horizontal className={css.layout}>
<Container className={css.searchContainer}>
<Search className={css.searchIcon} width={18} height={18} color="var(--ai-purple-600)" />
<SearchInputWithSpinner
placeholder={getString('codeSearchModal')}
spinnerPosition="right"
query={search}
type="text"
setQuery={q => {
if (q?.trim()) {
setSearchSampleQueryIndex(0)
}
setSearch(q)
}}
height={40}
onSearch={onSearch}
onKeyDown={e => {
if (!search?.trim()) {
switch (e.key) {
case 'ArrowDown':
setSearchSampleQueryIndex(index => {
return index + 1 > searchSampleQueries.length ? 1 : index + 1
})
break
case 'ArrowUp':
setSearchSampleQueryIndex(index => {
return index - 1 > 0 ? index - 1 : searchSampleQueries.length
})
break
}
}
}}
/>
{!search && <img src={svg} width={132} height={28} />}
</Container>
<Button
variation={ButtonVariation.AI}
text={getString('search')}
size={ButtonSize.MEDIUM}
onClick={onSearch}
/>
</Layout.Horizontal>
</Container>
<Text className={css.sectionHeader}>{getString('searchHeader')}</Text>
<Container>
<Layout.Vertical spacing="medium">
{searchSampleQueries.map((sampleQuery, index) => (
<Text
key={index}
className={cx(css.sampleQuery, { [css.selected]: index === searchSampleQueryIndex - 1 })}
{...ButtonRoleProps}
onClick={() => {
performSearch(sampleQuery)
}}>
{sampleQuery}
<LongArrowDownLeft color="" />
</Text>
))}
</Layout.Vertical>
</Container>
</Layout.Vertical>
</Container>
</Dialog>
</Container>
)}
</Container>
)}
</Container> </Container>
) )
} }
// These sample queries are in English only - No need to do i18n for them
const searchSampleQueries = [
`Where is the code that handles authentication?`,
`Where is the application entry point?`,
`Where do we configure the logger?`
]

View File

@ -22,6 +22,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
:global {
.highlight {
background-color: var(--yellow-300);
}
}
.heading { .heading {
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16); box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
align-items: center; align-items: center;

View File

@ -32,6 +32,8 @@ import { Color } from '@harnessio/design-system'
import { Document, Page, pdfjs } from 'react-pdf' import { Document, Page, pdfjs } from 'react-pdf'
import { Render, Match, Truthy, Falsy, Case, Else } from 'react-jsx-match' import { Render, Match, Truthy, Falsy, Case, Else } from 'react-jsx-match'
import { Link, useHistory } from 'react-router-dom' import { Link, useHistory } from 'react-router-dom'
import type { EditorDidMount } from 'react-monaco-editor'
import type { editor } from 'monaco-editor'
import { SourceCodeViewer } from 'components/SourceCodeViewer/SourceCodeViewer' import { SourceCodeViewer } from 'components/SourceCodeViewer/SourceCodeViewer'
import type { OpenapiContentInfo, RepoFileContent, TypesCommit } from 'services/code' import type { OpenapiContentInfo, RepoFileContent, TypesCommit } from 'services/code'
import { import {
@ -54,6 +56,7 @@ import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButto
import { PlainButton } from 'components/PlainButton/PlainButton' import { PlainButton } from 'components/PlainButton/PlainButton'
import { CommitsView } from 'components/CommitsView/CommitsView' import { CommitsView } from 'components/CommitsView/CommitsView'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam' import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { useQueryParams } from 'hooks/useQueryParams'
import { FileCategory, RepoContentExtended, useFileContentViewerDecision } from 'utils/FileUtils' import { FileCategory, RepoContentExtended, useFileContentViewerDecision } from 'utils/FileUtils'
import { useDownloadRawFile } from 'hooks/useDownloadRawFile' import { useDownloadRawFile } from 'hooks/useDownloadRawFile'
import { usePageIndex } from 'hooks/usePageIndex' import { usePageIndex } from 'hooks/usePageIndex'
@ -162,6 +165,34 @@ export function FileContent({
[editButtonDisabled, isFileTooLarge, category] [editButtonDisabled, isFileTooLarge, category]
) )
const { keyword } = useQueryParams<{ keyword?: string }>()
const onEditorMount: EditorDidMount = editor => {
if (!keyword) {
return
}
const editorModel = editor.getModel() as editor.ITextModel
const keywordMatches: editor.FindMatch[] = editorModel.findMatches(keyword, false, false, false, null, false)
if (keywordMatches.length > 0) {
keywordMatches.forEach((match: editor.FindMatch): void => {
editorModel.deltaDecorations(
[],
[
{
range: match.range,
options: {
isWholeLine: false,
inlineClassName: 'highlight'
}
}
]
)
})
}
}
return ( return (
<Container className={css.tabsContainer} ref={ref}> <Container className={css.tabsContainer} ref={ref}>
<Tabs <Tabs
@ -409,6 +440,7 @@ export function FileContent({
</Case> </Case>
<Case val={FileCategory.TEXT}> <Case val={FileCategory.TEXT}>
<SourceCodeViewer <SourceCodeViewer
editorDidMount={onEditorMount}
language={filenameToLanguage(filename)} language={filenameToLanguage(filename)}
source={decodeGitContent(base64Data)} source={decodeGitContent(base64Data)}
/> />

View File

@ -0,0 +1,332 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Container, Layout, SelectOption, Text, useToaster, useToggle } from '@harnessio/uicore'
import { Color, FontVariation } from '@harnessio/design-system'
import { Icon } from '@harnessio/icons'
import { Minus, Plus } from 'iconoir-react'
import {
Decoration,
DecorationSet,
EditorView,
MatchDecorator,
ViewPlugin,
ViewUpdate,
lineNumbers
} from '@codemirror/view'
import type { Extension } from '@codemirror/state'
import { Link } from 'react-router-dom'
import { useMutate } from 'restful-react'
import { debounce, escapeRegExp } from 'lodash-es'
import Keywords from 'react-keywords'
import cx from 'classnames'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { useQueryParams } from 'hooks/useQueryParams'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useUpdateQueryParams } from 'hooks/useUpdateQueryParams'
import { ButtonRoleProps, getErrorMessage } from 'utils/Utils'
import { Editor } from 'components/Editor/Editor'
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import KeywordSearchbar from 'components/KeywordSearchbar/KeywordSearchbar'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import KeywordSearchFilters from './KeywordSearchFilters'
import type { FileMatch, KeywordSearchResponse } from './KeywordSearch.types'
import css from './Search.module.scss'
const Search = () => {
const { getString } = useStrings()
const space = useGetSpaceParam()
const { repoName } = useGetRepositoryMetadata()
const { updateQueryParams } = useUpdateQueryParams()
const { showError } = useToaster()
const repoPath = repoName ? `${space}/${repoName}` : undefined
const { q } = useQueryParams<{ q: string }>()
const [searchTerm, setSearchTerm] = useState(q || '')
const [selectedRepositories, setSelectedRepositories] = useState<SelectOption[]>([])
const [selectedLanguage, setSelectedLanguage] = useState<string>()
const [searchResults, setSearchResults] = useState<KeywordSearchResponse>()
const { mutate, loading: isSearching } = useMutate<KeywordSearchResponse>({
path: `/api/v1/search`,
verb: 'POST'
})
const debouncedSearch = useCallback(
debounce(async (text: string) => {
try {
if (text.length > 2) {
const maxResultCount = Number(text.match(/count:(\d*)/)?.[1]) || 50
const repoPaths = selectedRepositories.map(option => String(option.value))
if (selectedLanguage) {
text += ` lang:${String(selectedLanguage)}`
}
const query = text.replace(/(?:repo|count):(?:[^\s]+|$)/g, '').trim()
const res = await mutate({
repo_paths: repoPath ? [repoPath] : repoPaths,
space_paths: !repoPath && !repoPaths.length ? [space] : [],
query,
max_result_count: maxResultCount
})
setSearchResults(res)
} else {
setSearchResults(undefined)
}
} catch (error) {
showError(getErrorMessage(error))
}
}, 300),
[selectedLanguage, selectedRepositories, repoPath]
)
useEffect(() => {
if (searchTerm) {
debouncedSearch(searchTerm)
}
}, [selectedLanguage, selectedRepositories])
return (
<Container className={css.main}>
<Container padding="medium" border={{ bottom: true }} flex className={css.header}>
<KeywordSearchbar
value={searchTerm}
onChange={text => {
setSearchResults(undefined)
setSearchTerm(text)
updateQueryParams({ q: text })
debouncedSearch(text)
}}
/>
</Container>
<Container padding="xlarge">
<LoadingSpinner visible={isSearching} />
<KeywordSearchFilters
isRepoLevelSearch={Boolean(repoName)}
selectedLanguage={selectedLanguage}
selectedRepositories={selectedRepositories}
setLanguage={setSelectedLanguage}
setRepositories={setSelectedRepositories}
/>
{searchResults?.file_matches.length ? (
<>
<Layout.Horizontal spacing="xsmall" margin={{ bottom: 'large' }}>
<Text font={{ variation: FontVariation.SMALL_SEMI }}>
{searchResults?.stats.total_files} {getString('files')}
</Text>
<Text font={{ variation: FontVariation.SMALL_SEMI }} color={Color.GREY_400}>
{getString('results')}
</Text>
</Layout.Horizontal>
{searchResults?.file_matches?.map(fileMatch => {
return <SearchResult key={fileMatch.file_name} fileMatch={fileMatch} searchTerm={searchTerm} />
})}
</>
) : null}
<NoResultCard showWhen={() => !isSearching && !searchResults?.file_matches?.length} forSearch={true} />
</Container>
</Container>
)
}
export default Search
interface CodeBlock {
lineNumberOffset: number
codeBlock: string
}
export const SearchResult = ({ fileMatch, searchTerm }: { fileMatch: FileMatch; searchTerm: string }) => {
const { routes } = useAppContext()
const { getString } = useStrings()
const [isCollapsed, setIsCollapsed] = useToggle(false)
const [showMoreMatchs, setShowMoreMatches] = useState(false)
const matchDecoratorPlugin: Extension = useMemo(() => {
const placeholderMatcher = new MatchDecorator({
regexp: new RegExp(`${escapeRegExp(fileMatch.matches[0].fragments[0].match)}`, 'gi'),
decoration: Decoration.mark({ class: css.highlight })
})
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view)
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders)
}
},
{
decorations: instance => instance.placeholders,
provide: plugin =>
EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.placeholders || Decoration.none
})
}
)
}, [])
const codeBlocks: CodeBlock[] = useMemo(() => {
const codeBlocksArr: Array<{ lineNumberOffset: number; codeBlock: string }> = []
fileMatch.matches.forEach(keywordMatch => {
const lines: string[] = []
if (keywordMatch.before.trim()) {
lines.push(keywordMatch.before)
}
const line: string[] = []
keywordMatch.fragments.forEach(fragmentMatch => {
line.push(fragmentMatch.pre + fragmentMatch.match + fragmentMatch.post)
})
lines.push(line.join(''))
if (keywordMatch.after.trim()) {
lines.push(keywordMatch.after)
}
const codeBlock = lines.join('\n')
const lineNumberOffset = keywordMatch.before.trim()
? keywordMatch.line_num - Math.floor(codeBlock.split('\n').length / 2)
: keywordMatch.line_num
codeBlocksArr.push({
lineNumberOffset,
codeBlock
})
})
return codeBlocksArr
}, [fileMatch])
const collapsedCodeBlocks = showMoreMatchs ? codeBlocks.slice(0, 25) : codeBlocks.slice(0, 2)
const repoName = fileMatch.repo_path.split('/').pop()
return (
<Container className={css.searchResult}>
<Layout.Horizontal spacing="small" className={css.resultHeader}>
<Icon name={isCollapsed ? 'chevron-up' : 'chevron-down'} {...ButtonRoleProps} onClick={setIsCollapsed} />
<Link to={routes.toCODERepository({ repoPath: fileMatch.repo_path })}>
<Text
icon="code-repo"
font={{ variation: FontVariation.SMALL_SEMI }}
color={Color.GREY_500}
border={{ right: true }}
padding={{ right: 'small' }}>
{repoName}
</Text>
</Link>
<Link
to={routes.toCODERepository({
repoPath: fileMatch.repo_path,
gitRef: fileMatch.repo_branch,
resourcePath: fileMatch.file_name
})}>
<Text font={{ variation: FontVariation.SMALL_BOLD }} color={Color.PRIMARY_7}>
<Keywords value={searchTerm} backgroundColor="var(--yellow-300">
{fileMatch.file_name}
</Keywords>
</Text>
</Link>
</Layout.Horizontal>
<div className={cx({ [css.isCollapsed]: isCollapsed })}>
{collapsedCodeBlocks.map((codeBlock, index) => {
const showMoreMatchesFooter = codeBlocks.length > 2 && index === collapsedCodeBlocks.length - 1
/** File Match */
if (codeBlock.lineNumberOffset === 0) {
return null
}
return (
<>
<Editor
inGitBlame
content={codeBlock.codeBlock}
standalone={true}
readonly={true}
repoMetadata={undefined}
filename={fileMatch.file_name}
extensions={[
matchDecoratorPlugin,
lineNumbers({
formatNumber: n => String(n - 1 + codeBlock.lineNumberOffset)
})
]}
className={css.editorCtn}
/>
{showMoreMatchesFooter ? (
<Layout.Horizontal
spacing="small"
className={css.showMoreCtn}
onClick={() => setShowMoreMatches(prevVal => !prevVal)}
flex={{ alignItems: 'center', justifyContent: 'flex-start' }}
{...ButtonRoleProps}>
{!showMoreMatchs ? <Plus /> : <Minus />}
<Text font={{ variation: FontVariation.TINY_SEMI }} color={Color.GREY_400}>
{!showMoreMatchs
? getString('showNMoreMatches', { n: codeBlocks.length - 2 })
: getString('showLessMatches')}
</Text>
{codeBlocks.length > 25 && showMoreMatchs ? (
<Text
font={{ variation: FontVariation.TINY }}
border={{ left: true }}
padding={{ left: 'small' }}
color={Color.GREY_400}>
{getString('nMoreMatches', { n: codeBlocks.length - 25 })}{' '}
<Link
target="_blank"
referrerPolicy="no-referrer"
to={`${routes.toCODERepository({
repoPath: fileMatch.repo_path,
gitRef: fileMatch.repo_branch,
resourcePath: fileMatch.file_name
})}?keyword=${searchTerm}`}>
{getString('seeNMoreMatches', { n: codeBlocks.length })}
</Link>
</Text>
) : null}
</Layout.Horizontal>
) : null}
</>
)
})}
</div>
</Container>
)
}

View File

@ -0,0 +1,30 @@
export interface KeywordSearchResponse {
file_matches: FileMatch[]
stats: Stats
}
export interface FileMatch {
file_name: string
repo_path: string
repo_branch: string
language: string
matches: Match[]
}
export interface Match {
line_num: number
fragments: Fragment[]
before: string
after: string
}
export interface Fragment {
pre: string
match: string
post: string
}
export interface Stats {
total_files: number
total_matches: number
}

View File

@ -0,0 +1,107 @@
import React, { Dispatch, SetStateAction } from 'react'
import {
Container,
Text,
type SelectOption,
MultiSelectDropDown,
Button,
ButtonVariation,
DropDown
} from '@harnessio/uicore'
import { Color, FontVariation } from '@harnessio/design-system'
import { useGet } from 'restful-react'
import { useStrings } from 'framework/strings'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import type { TypesRepository } from 'services/code'
import css from './Search.module.scss'
const languageOptions = [
{ label: 'JavaScript', value: 'javascript' },
{ label: 'Python', value: 'python' },
{ label: 'Java', value: 'java' },
{ label: 'C#', value: 'csharp' },
{ label: 'PHP', value: 'php' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'C++', value: 'cpp' },
{ label: 'C', value: 'c' },
{ label: 'Ruby', value: 'ruby' },
{ label: 'Go', value: 'go' },
{ label: 'Swift', value: 'swift' },
{ label: 'Kotlin', value: 'kotlin' },
{ label: 'Rust', value: 'rust' },
{ label: 'Scala', value: 'scala' }
]
interface KeywordSearchFiltersProps {
isRepoLevelSearch?: boolean
selectedRepositories: SelectOption[]
setRepositories: Dispatch<SetStateAction<SelectOption[]>>
selectedLanguage?: string
setLanguage: Dispatch<SetStateAction<string | undefined>>
}
const KeywordSearchFilters: React.FC<KeywordSearchFiltersProps> = ({
isRepoLevelSearch,
selectedLanguage,
selectedRepositories,
setLanguage,
setRepositories
}) => {
const { getString } = useStrings()
const space = useGetSpaceParam()
const { data } = useGet<TypesRepository[]>({
path: `/api/v1/spaces/${space}/+/repos`,
debounce: 500,
lazy: isRepoLevelSearch
})
const repositoryOptions =
data?.map(repository => ({
label: String(repository.uid),
value: String(repository.path)
})) || []
return (
<div className={css.filtersCtn}>
{isRepoLevelSearch ? null : (
<Container>
<Text font={{ variation: FontVariation.SMALL_SEMI }} color={Color.GREY_600} margin={{ bottom: 'xsmall' }}>
{getString('pageTitle.repository')}
</Text>
<MultiSelectDropDown
className={css.multiSelect}
value={selectedRepositories}
placeholder={selectedRepositories.length ? '' : getString('selectRepositoryPlaceholder')}
onChange={setRepositories}
items={repositoryOptions}
/>
</Container>
)}
<Container>
<Text font={{ variation: FontVariation.SMALL_SEMI }} color={Color.GREY_600} margin={{ bottom: 'xsmall' }}>
{getString('language')}
</Text>
<DropDown
className={css.multiSelect}
value={selectedLanguage}
placeholder={getString('selectLanguagePlaceholder')}
onChange={option => setLanguage(String(option.value))}
items={languageOptions}
/>
</Container>
<Button
variation={ButtonVariation.LINK}
text={getString('clear')}
onClick={() => {
setRepositories([])
setLanguage(undefined)
}}
/>
</div>
)
}
export default KeywordSearchFilters

View File

@ -249,3 +249,93 @@
display: none !important; display: none !important;
} }
} }
.header {
background-color: var(--white) !important;
}
.filtersCtn {
align-items: flex-end;
display: grid;
grid-template-columns: max-content max-content min-content;
column-gap: var(--spacing-medium);
margin-bottom: var(--spacing-large);
:global {
button {
--padding-left: 0 !important;
--font-weight: 500 !important;
}
}
.multiSelect {
min-width: 203px;
}
}
.searchResult {
background-color: var(--grey-100) !important;
border-radius: 4px;
margin-bottom: var(--spacing-medium) !important;
.resultHeader {
padding: var(--spacing-small);
}
.showMoreCtn {
background-color: var(--grey-50) !important;
border: 1px solid var(--grey-100) !important;
border-top: 0 !important;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
padding: var(--spacing-small) var(--spacing-4) !important;
}
.isCollapsed {
display: none;
}
.editorCtn {
:global {
.cm-editor {
border: 1px solid var(--grey-100) !important;
min-height: unset;
}
.cm-scroller {
padding: 0 !important;
.cm-line,
.cm-gutterElement {
&,
* {
@include mono-font;
}
}
}
.cm-gutters {
border-right: 0;
}
.cm-gutter.cm-lineNumbers {
padding-left: var(--spacing-small);
background-color: var(--grey-50);
}
.cm-content.cm-lineWrapping {
padding: var(--spacing-small);
}
}
}
.editorCtn ~ .editorCtn {
> div {
border-top: 0 !important;
}
}
}
.highlight {
background-color: var(--yellow-300);
}

View File

@ -17,12 +17,18 @@
/* eslint-disable */ /* eslint-disable */
// This is an auto-generated file // This is an auto-generated file
export declare const aiLabel: string export declare const aiLabel: string
export declare const editorCtn: string
export declare const fileContent: string export declare const fileContent: string
export declare const filename: string export declare const filename: string
export declare const filePath: string export declare const filePath: string
export declare const filtersCtn: string
export declare const header: string
export declare const highlight: string
export declare const highlightLineNumber: string export declare const highlightLineNumber: string
export declare const isCollapsed: string
export declare const layout: string export declare const layout: string
export declare const main: string export declare const main: string
export declare const multiSelect: string
export declare const noResult: string export declare const noResult: string
export declare const noResultContainer: string export declare const noResultContainer: string
export declare const pageHeader: string export declare const pageHeader: string
@ -30,9 +36,11 @@ export declare const path: string
export declare const pathText: string export declare const pathText: string
export declare const preview: string export declare const preview: string
export declare const result: string export declare const result: string
export declare const resultHeader: string
export declare const resultTitle: string export declare const resultTitle: string
export declare const searchBox: string export declare const searchBox: string
export declare const searchResult: string export declare const searchResult: string
export declare const selected: string export declare const selected: string
export declare const showMoreCtn: string
export declare const split: string export declare const split: string
export declare const texts: string export declare const texts: string