diff --git a/src/assets/icons/icon-search-empty.svg b/src/assets/icons/icon-search-empty.svg
new file mode 100644
index 0000000000..5dc84c4f71
--- /dev/null
+++ b/src/assets/icons/icon-search-empty.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/modules/search/components/AppBarSearch.spec.jsx b/src/modules/search/components/AppBarSearch.spec.jsx
new file mode 100644
index 0000000000..78401ede6e
--- /dev/null
+++ b/src/modules/search/components/AppBarSearch.spec.jsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react'
+import React from 'react'
+
+import CozyClient from 'cozy-client'
+
+import AppBarSearch from 'modules/search/components/AppBarSearch'
+import AppLike from 'test/components/AppLike'
+
+it('should display the Searchbar', () => {
+ const client = new CozyClient({})
+ render(
+
+
+
+ )
+ expect(screen.getByPlaceholderText('Search anything')).toBeInTheDocument()
+})
diff --git a/src/modules/search/components/BarSearchAutosuggest.jsx b/src/modules/search/components/BarSearchAutosuggest.jsx
new file mode 100644
index 0000000000..40c7db23a7
--- /dev/null
+++ b/src/modules/search/components/BarSearchAutosuggest.jsx
@@ -0,0 +1,148 @@
+import cx from 'classnames'
+import React, { useState } from 'react'
+import Autosuggest from 'react-autosuggest'
+
+import { models, useClient } from 'cozy-client'
+import { isFlagshipApp } from 'cozy-device-helper'
+import { useWebviewIntent } from 'cozy-intent'
+import List from 'cozy-ui/transpiled/react/List'
+
+import { SHARED_DRIVES_DIR_ID } from 'constants/config'
+import BarSearchInputGroup from 'modules/search/components/BarSearchInputGroup'
+import SuggestionItem from 'modules/search/components/SuggestionItem'
+import SuggestionListSkeleton from 'modules/search/components/SuggestionListSkeleton'
+import useSearch from 'modules/search/hooks/useSearch'
+
+import styles from 'modules/search/components/styles.styl'
+
+const BarSearchAutosuggest = ({ t }) => {
+ const webviewIntent = useWebviewIntent()
+ const client = useClient()
+
+ const [input, setInput] = useState('')
+ const [searchTerm, setSearchTerm] = useState('')
+ const { suggestions, hasSuggestions, isBusy, query, makeIndexes } =
+ useSearch(searchTerm)
+ const [focused, setFocused] = useState(false)
+
+ const theme = {
+ container: 'u-w-100',
+ suggestionsContainer:
+ styles['bar-search-autosuggest-suggestions-container'],
+ suggestionsContainerOpen:
+ styles['bar-search-autosuggest-suggestions-container--open'],
+ suggestionsList: styles['bar-search-autosuggest-suggestions-list']
+ }
+
+ const onSuggestionsFetchRequested = ({ value }) => {
+ setSearchTerm(value)
+ }
+ const onSuggestionsClearRequested = () => {
+ setSearchTerm('')
+ }
+
+ const cleanSearch = () => {
+ setInput('')
+ setSearchTerm('')
+ }
+
+ const onSuggestionSelected = async (event, { suggestion }) => {
+ // Open the shared drive in a new tab
+ if (suggestion.parentUrl?.includes(SHARED_DRIVES_DIR_ID)) {
+ window.open(`/#/external/${suggestion.id}`, '_blank')
+ return cleanSearch()
+ }
+
+ let url = `${window.location.origin}/#${suggestion.url}`
+ if (suggestion.openOn === 'notes') {
+ url = await models.note.fetchURL(client, {
+ id: suggestion.url.substr(3)
+ })
+ }
+
+ if (url) {
+ if (isFlagshipApp()) {
+ webviewIntent.call('openApp', url, { slug: suggestion.openOn })
+ } else {
+ window.location.assign(url)
+ }
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(`openSuggestion (${suggestion.name}) could not be executed`)
+ }
+ cleanSearch()
+ }
+
+ // We want the user to find folders in which he can then navigate into, so we return the path here
+ const getSuggestionValue = suggestion => suggestion.subtitle
+
+ const renderSuggestion = suggestion => {
+ return (
+
+ )
+ }
+
+ const inputProps = {
+ placeholder: t('searchbar.placeholder'),
+ value: input,
+ onChange: (event, { newValue }) => {
+ setInput(newValue)
+ },
+ onFocus: () => {
+ makeIndexes()
+ setFocused(true)
+ },
+ onBlur: () => setFocused(false)
+ }
+
+ const renderInputComponent = inputProps => (
+
+
+
+ )
+
+ const renderSuggestionsContainer = ({ containerProps, children }) => {
+ return {children}
+ }
+
+ const hasNoSearchResult = searchTerm !== '' && focused && !hasSuggestions
+
+ return (
+
+
+ {hasNoSearchResult && !isBusy && (
+
+ {t('searchbar.empty', { query })}
+
+ )}
+ {hasNoSearchResult && isBusy && (
+
+
+
+ )}
+
+ )
+}
+
+export default BarSearchAutosuggest
diff --git a/src/modules/search/components/BarSearchInputGroup.jsx b/src/modules/search/components/BarSearchInputGroup.jsx
new file mode 100644
index 0000000000..92dc93ea0c
--- /dev/null
+++ b/src/modules/search/components/BarSearchInputGroup.jsx
@@ -0,0 +1,43 @@
+import React from 'react'
+
+import Icon from 'cozy-ui/transpiled/react/Icon'
+import IconButton from 'cozy-ui/transpiled/react/IconButton'
+import CrossCircleOutlineIcon from 'cozy-ui/transpiled/react/Icons/CrossCircleOutline'
+import Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'
+import InputGroup from 'cozy-ui/transpiled/react/InputGroup'
+
+import styles from 'modules/search/components/styles.styl'
+
+const BarSearchInputGroup = ({
+ children,
+ isMobile,
+ onClean,
+ isInputNotEmpty
+}) => {
+ return (
+
+ ) : null
+ }
+ append={
+ isInputNotEmpty ? (
+
+
+
+ ) : null
+ }
+ >
+ {children}
+
+ )
+}
+
+export default BarSearchInputGroup
diff --git a/src/modules/search/components/SearchEmpty.jsx b/src/modules/search/components/SearchEmpty.jsx
new file mode 100644
index 0000000000..f7ae8c674f
--- /dev/null
+++ b/src/modules/search/components/SearchEmpty.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+import Grid from 'cozy-ui/transpiled/react/Grid'
+import Icon from 'cozy-ui/transpiled/react/Icon'
+import Typography from 'cozy-ui/transpiled/react/Typography'
+import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n'
+
+import searchEmptyIllustration from 'assets/icons/icon-search-empty.svg'
+
+const SearchEmpty = ({ query }) => {
+ const { t } = useI18n()
+
+ return (
+
+
+
+
+
+
+ {t('search.empty.title', { query })}
+
+
+
+
+ {t('search.empty.subtitle', { query })}
+
+
+
+ )
+}
+
+export default SearchEmpty
diff --git a/src/modules/search/components/SuggestionItem.jsx b/src/modules/search/components/SuggestionItem.jsx
new file mode 100644
index 0000000000..c37810da26
--- /dev/null
+++ b/src/modules/search/components/SuggestionItem.jsx
@@ -0,0 +1,63 @@
+import React, { useCallback } from 'react'
+
+import ListItem from 'cozy-ui/transpiled/react/ListItem'
+import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
+import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
+
+import { SHARED_DRIVES_DIR_ID } from 'constants/config'
+import FileIconMime from 'modules/filelist/icons/FileIconMime'
+import FileIconShortcut from 'modules/filelist/icons/FileIconShortcut'
+import SuggestionItemTextHighlighted from 'modules/search/components/SuggestionItemTextHighlighted'
+import SuggestionItemTextSecondary from 'modules/search/components/SuggestionItemTextSecondary'
+
+const SuggestionItem = ({
+ suggestion,
+ query,
+ onClick,
+ onParentOpened,
+ isMobile = false
+}) => {
+ const openSuggestion = useCallback(() => {
+ if (typeof onClick == 'function') {
+ onClick(suggestion)
+ }
+ }, [onClick, suggestion])
+
+ const file = {
+ class: suggestion.class,
+ type: suggestion.type,
+ mime: suggestion.mime,
+ name: suggestion.title.replace(/\.url$/, ''), // Not using `splitFileName()` because we don't have access to the full file here.
+ parentUrl: suggestion.parentUrl
+ }
+
+ return (
+
+
+ {file.class === 'shortcut' ? (
+
+ ) : (
+
+ )}
+
+
+ }
+ secondary={
+ file.parentUrl?.includes(SHARED_DRIVES_DIR_ID) ? null : (
+
+ )
+ }
+ />
+
+ )
+}
+
+export default SuggestionItem
diff --git a/src/modules/search/components/SuggestionItemSkeleton.jsx b/src/modules/search/components/SuggestionItemSkeleton.jsx
new file mode 100644
index 0000000000..a39fae978f
--- /dev/null
+++ b/src/modules/search/components/SuggestionItemSkeleton.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+import ListItem from 'cozy-ui/transpiled/react/ListItem'
+import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
+import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
+import Skeleton from 'cozy-ui/transpiled/react/Skeleton'
+
+const SuggestionItemSkeleton = () => {
+ return (
+
+
+
+
+
+ }
+ secondary={
+
+ }
+ />
+
+ )
+}
+
+export default SuggestionItemSkeleton
diff --git a/src/modules/search/components/SuggestionItemTextHighlighted.jsx b/src/modules/search/components/SuggestionItemTextHighlighted.jsx
new file mode 100644
index 0000000000..1cb6ff5049
--- /dev/null
+++ b/src/modules/search/components/SuggestionItemTextHighlighted.jsx
@@ -0,0 +1,104 @@
+import React from 'react'
+
+import { normalizeString } from 'modules/search/components/helpers'
+
+/**
+ * Add on part that equlas query into each result
+ *
+ * @param {Array} searchResult - list of results
+ * @param {string} query - search input
+ * @returns list of results with the query highlighted
+ */
+const highlightQueryTerms = (searchResult, query) => {
+ const normalizedQueryTerms = normalizeString(query)
+ const normalizedResultTerms = normalizeString(searchResult)
+
+ const matchedIntervals = []
+ const spacerLength = 1
+ let currentIndex = 0
+
+ normalizedResultTerms.forEach(resultTerm => {
+ normalizedQueryTerms.forEach(queryTerm => {
+ const index = resultTerm.indexOf(queryTerm)
+ if (index >= 0) {
+ matchedIntervals.push({
+ from: currentIndex + index,
+ to: currentIndex + index + queryTerm.length
+ })
+ }
+ })
+
+ currentIndex += resultTerm.length + spacerLength
+ })
+
+ // matchedIntervals can overlap, so we merge them.
+ // - sort the intervals by starting index
+ // - add the first interval to the stack
+ // - for every interval,
+ // - - add it to the stack if it doesn't overlap with the stack top
+ // - - or extend the stack top if the start overlaps and the new interval's top is bigger
+ const mergedIntervals = matchedIntervals
+ .sort((intervalA, intervalB) => intervalA.from > intervalB.from)
+ .reduce((computedIntervals, newInterval) => {
+ if (
+ computedIntervals.length === 0 ||
+ computedIntervals[computedIntervals.length - 1].to < newInterval.from
+ ) {
+ computedIntervals.push(newInterval)
+ } else if (
+ computedIntervals[computedIntervals.length - 1].to < newInterval.to
+ ) {
+ computedIntervals[computedIntervals.length - 1].to = newInterval.to
+ }
+
+ return computedIntervals
+ }, [])
+
+ // create an array containing the entire search result, with special characters, and the intervals surrounded y `` tags
+ const slicedOriginalResult =
+ mergedIntervals.length > 0
+ ? [{searchResult.slice(0, mergedIntervals[0].from)}]
+ : searchResult
+
+ for (let i = 0, l = mergedIntervals.length; i < l; ++i) {
+ slicedOriginalResult.push(
+
+ {searchResult.slice(mergedIntervals[i].from, mergedIntervals[i].to)}
+
+ )
+ if (i + 1 < l)
+ slicedOriginalResult.push(
+
+ {searchResult.slice(
+ mergedIntervals[i].to,
+ mergedIntervals[i + 1].from
+ )}
+
+ )
+ }
+
+ if (mergedIntervals.length > 0)
+ slicedOriginalResult.push(
+
+ {searchResult.slice(
+ mergedIntervals[mergedIntervals.length - 1].to,
+ searchResult.length
+ )}
+
+ )
+
+ return slicedOriginalResult
+}
+
+const SuggestionItemTextHighlighted = ({ text, query }) => {
+ const textHighlighted = highlightQueryTerms(text, query)
+ if (Array.isArray(textHighlighted)) {
+ return textHighlighted.map((item, idx) => ({
+ ...item,
+ key: idx
+ }))
+ }
+ return textHighlighted
+}
+
+export default SuggestionItemTextHighlighted
diff --git a/src/modules/search/components/SuggestionItemTextSecondary.jsx b/src/modules/search/components/SuggestionItemTextSecondary.jsx
new file mode 100644
index 0000000000..8680a0b08d
--- /dev/null
+++ b/src/modules/search/components/SuggestionItemTextSecondary.jsx
@@ -0,0 +1,66 @@
+import React from 'react'
+
+import { generateWebLink, useClient } from 'cozy-client'
+import { isFlagshipApp } from 'cozy-device-helper'
+import AppLinker, {
+ generateUniversalLink
+} from 'cozy-ui/transpiled/react/AppLinker'
+
+import SuggestionItemTextHighlighted from 'modules/search/components/SuggestionItemTextHighlighted'
+
+import styles from 'modules/search/components/styles.styl'
+
+const SuggestionItemTextSecondary = ({
+ text,
+ query,
+ url,
+ onOpened,
+ isMobile
+}) => {
+ const client = useClient()
+
+ if (isMobile) {
+ return
+ }
+
+ const app = {
+ slug: 'drive'
+ }
+
+ const { subdomain: subDomainType } = client.getInstanceOptions()
+ const generateLink = isFlagshipApp() ? generateUniversalLink : generateWebLink
+
+ const appWebRef =
+ app &&
+ generateLink({
+ slug: 'drive',
+ cozyUrl: client.getStackClient().uri,
+ subDomainType,
+ nativePath: url,
+ pathname: '/',
+ hash: url
+ })
+ return (
+
+ {({ onClick, href }) => (
+ {
+ e.stopPropagation()
+ if (typeof onOpened == 'function') {
+ onOpened(e)
+ }
+ if (typeof onClick == 'function') {
+ onClick(e)
+ }
+ }}
+ >
+
+
+ )}
+
+ )
+}
+
+export default SuggestionItemTextSecondary
diff --git a/src/modules/search/components/SuggestionListSkeleton.jsx b/src/modules/search/components/SuggestionListSkeleton.jsx
new file mode 100644
index 0000000000..f6011386b9
--- /dev/null
+++ b/src/modules/search/components/SuggestionListSkeleton.jsx
@@ -0,0 +1,17 @@
+import React from 'react'
+
+import List from 'cozy-ui/transpiled/react/List'
+
+import SuggestionItemSkeleton from 'modules/search/components/SuggestionItemSkeleton'
+
+const SuggestionListSkeleton = ({ count }) => (
+
+ {Array(count || 4)
+ .fill(1)
+ .map((_, i) => (
+
+ ))}
+
+)
+
+export default SuggestionListSkeleton
diff --git a/src/modules/search/components/helpers.js b/src/modules/search/components/helpers.js
new file mode 100644
index 0000000000..2f4594ed46
--- /dev/null
+++ b/src/modules/search/components/helpers.js
@@ -0,0 +1,114 @@
+import { models } from 'cozy-client'
+
+import { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from 'constants/config'
+import FuzzyPathSearch from 'lib/FuzzyPathSearch.js'
+import { isEncryptedFolder } from 'lib/encryption'
+import { makeOnlyOfficeFileRoute } from 'modules/views/OnlyOffice/helpers'
+
+export const TYPE_DIRECTORY = 'directory'
+
+export const normalizeString = str =>
+ str
+ .toString()
+ .toLowerCase()
+ .replace(/\//g, ' ')
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .split(' ')
+
+/**
+ * Normalize file for Front usage in component inside
+ *
+ * To reduce API call, the fetching of Note URL has been delayed
+ * inside an onSelect function called only if provided to
+ * see https://github.com/cozy/cozy-drive/pull/2663#discussion_r938671963
+ *
+ * @param {CozyClient} client - cozy client instance
+ * @param {[IOCozyFile]} folders - all the folders returned by API
+ * @param {IOCozyFile} file - file to normalize
+ * @returns file with normalized field to be used in AutoSuggestion
+ */
+export const makeNormalizedFile = (client, folders, file) => {
+ const isDir = file.type === TYPE_DIRECTORY
+ const dirId = isDir ? file._id : file.dir_id
+ const urlToFolder = `/folder/${dirId}`
+
+ let path, url, parentUrl
+ let openOn = 'drive'
+ if (isDir) {
+ path = file.path
+ url = urlToFolder
+ parentUrl = urlToFolder
+ } else {
+ const parentDir = folders.find(folder => folder._id === file.dir_id)
+ path = parentDir && parentDir.path ? parentDir.path : ''
+ parentUrl = parentDir && parentDir._id ? `/folder/${parentDir._id}` : ''
+ if (models.file.isNote(file)) {
+ url = `/n/${file.id}`
+ openOn = 'notes'
+ } else if (models.file.shouldBeOpenedByOnlyOffice(file)) {
+ url = makeOnlyOfficeFileRoute(file.id, { fromPathname: urlToFolder })
+ } else {
+ url = `${urlToFolder}/file/${file._id}`
+ }
+ }
+
+ return {
+ id: file._id,
+ type: file.type,
+ name: file.name,
+ mime: file.mime,
+ class: file.class,
+ path,
+ url,
+ parentUrl,
+ openOn,
+ isEncrypted: isEncryptedFolder(file)
+ }
+}
+
+/**
+ * Fetches all files without trashed and preloads FuzzyPathSearch
+ *
+ * Using _all_docs route
+ *
+ * Also, this method:
+ * - removing trashed data directly
+ * - removes orphan file
+ * - normalize file to match expectation
+ * - preloads FuzzyPathSearch
+ *
+ * @returns {Promise} nothing
+ */
+export const indexFiles = async client => {
+ const resp = await client
+ .getStackClient()
+ .fetchJSON(
+ 'GET',
+ '/data/io.cozy.files/_all_docs?Fields=_id,trashed,dir_id,name,path,type,mime,class,metadata.title,metadata.version&DesignDocs=false&include_docs=true'
+ )
+ const files = resp.rows.map(row => ({ id: row.id, ...row.doc }))
+ const folders = files.filter(file => file.type === TYPE_DIRECTORY)
+
+ const notInTrash = file => !file.trashed && !/^\/\.cozy_trash/.test(file.path)
+ const notOrphans = file =>
+ folders.find(folder => folder._id === file.dir_id) !== undefined
+ const notRoot = file => file._id !== ROOT_DIR_ID
+ // Shared drives folder to be hidden in search.
+ // The files inside it though must appear. Thus only the file with the folder ID is filtered out.
+ const notSharedDrivesDir = file => file._id !== SHARED_DRIVES_DIR_ID
+
+ const normalizedFilesPrevious = files.filter(
+ file =>
+ notInTrash(file) &&
+ notOrphans(file) &&
+ notRoot(file) &&
+ notSharedDrivesDir(file)
+ )
+
+ const normalizedFiles = normalizedFilesPrevious.map(file =>
+ makeNormalizedFile(client, folders, file)
+ )
+
+ return new FuzzyPathSearch(normalizedFiles)
+}
diff --git a/src/modules/search/components/helpers.spec.jsx b/src/modules/search/components/helpers.spec.jsx
new file mode 100644
index 0000000000..e263019295
--- /dev/null
+++ b/src/modules/search/components/helpers.spec.jsx
@@ -0,0 +1,118 @@
+import { createMockClient, models } from 'cozy-client'
+
+import { makeNormalizedFile, TYPE_DIRECTORY } from './helpers'
+
+models.note.fetchURL = jest.fn(() => 'noteUrl')
+
+const client = createMockClient({})
+
+const noteFileProps = {
+ name: 'note.cozy-note',
+ metadata: {
+ content: '',
+ schema: '',
+ title: '',
+ version: ''
+ }
+}
+
+describe('makeNormalizedFile', () => {
+ it('should return correct values for a directory', () => {
+ const folders = []
+ const file = {
+ _id: 'fileId',
+ type: TYPE_DIRECTORY,
+ path: 'filePath',
+ name: 'fileName'
+ }
+
+ const normalizedFile = makeNormalizedFile(client, folders, file)
+
+ expect(normalizedFile).toEqual({
+ id: 'fileId',
+ name: 'fileName',
+ path: 'filePath',
+ url: '/folder/fileId',
+ parentUrl: '/folder/fileId',
+ openOn: 'drive',
+ isEncrypted: false,
+ mime: undefined,
+ type: 'directory'
+ })
+ })
+
+ it('should return correct values for a file', () => {
+ const folders = [{ _id: 'folderId', path: 'folderPath' }]
+ const file = {
+ _id: 'fileId',
+ dir_id: 'folderId',
+ type: 'file',
+ name: 'fileName'
+ }
+
+ const normalizedFile = makeNormalizedFile(client, folders, file)
+
+ expect(normalizedFile).toEqual({
+ id: 'fileId',
+ name: 'fileName',
+ path: 'folderPath',
+ url: '/folder/folderId/file/fileId',
+ parentUrl: '/folder/folderId',
+ openOn: 'drive',
+ isEncrypted: false,
+ mime: undefined,
+ type: 'file'
+ })
+ })
+
+ it('should return correct values for a note with on Select function - better for performance', () => {
+ const folders = [{ _id: 'folderId', path: 'folderPath' }]
+ const file = {
+ _id: 'fileId',
+ id: 'noteId',
+ dir_id: 'folderId',
+ type: 'file',
+ name: 'fileName',
+ ...noteFileProps
+ }
+
+ const normalizedFile = makeNormalizedFile(client, folders, file)
+
+ expect(normalizedFile).toEqual({
+ id: 'fileId',
+ name: 'note.cozy-note',
+ path: 'folderPath',
+ url: '/n/noteId',
+ parentUrl: '/folder/folderId',
+ openOn: 'notes',
+ isEncrypted: false,
+ mime: undefined,
+ type: 'file'
+ })
+ })
+
+ it('should not return filled onSelect for a note without metadata', () => {
+ const folders = [{ _id: 'folderId', path: 'folderPath' }]
+ const file = {
+ _id: 'fileId',
+ id: 'noteId',
+ dir_id: 'folderId',
+ type: 'file',
+ name: 'note.cozy-note'
+ }
+
+ const normalizedFile = makeNormalizedFile(client, folders, file)
+
+ expect(normalizedFile).toEqual({
+ id: 'fileId',
+ name: 'note.cozy-note',
+ path: 'folderPath',
+ url: '/folder/folderId/file/fileId',
+ parentUrl: '/folder/folderId',
+ openOn: 'drive',
+ isEncrypted: false,
+ mime: undefined,
+ type: 'file'
+ })
+ })
+})
diff --git a/src/modules/search/components/styles.styl b/src/modules/search/components/styles.styl
new file mode 100644
index 0000000000..db89498f03
--- /dev/null
+++ b/src/modules/search/components/styles.styl
@@ -0,0 +1,82 @@
+[role=banner]
+ .bar-search-autosuggest-suggestions-container
+ position absolute
+ top 100%
+ width 100%
+ max-height em(170px)
+ overflow auto
+ border-radius .5em
+ color var(--primaryTextColor)
+ background var(--paperBackgroundColor)
+ box-shadow var(--shadow7)
+ display none
+ box-sizing border-box
+
+ .bar-search-autosuggest-suggestions-container--open
+ display block
+
+ .bar-search-autosuggest-status-container
+ position absolute
+ display flex
+ align-items center
+ top 100%
+ left 0
+ right 0
+ min-height 48px
+ max-height em(170px)
+ overflow auto
+ border-radius .5em
+ background var(--paperBackgroundColor)
+ box-shadow var(--shadow7)
+ box-sizing border-box
+
+ &.--empty
+ padding .75em 1em
+
+ .bar-search-autosuggest-suggestions-list
+ margin 0
+ padding 0
+ list-style none
+
+ .bar-search-container
+ position relative
+ display flex
+ align-items center
+ flex-grow 1
+ margin-left 2em
+ margin-right 2em
+ padding-top .25em
+ padding-bottom .25em
+
+ &.mobile
+ margin-left 0
+ margin-right -.5em
+
+ .bar-search-input-group
+ border 0
+ max-height 40px
+ padding-left .5em
+ border-radius 1.25em
+ background-color var(--defaultBackgroundColor)
+ transition all .2s ease-out
+ overflow hidden
+
+ &:hover
+ background linear-gradient(0deg, var(--actionColorHover), var(--actionColorHover)), var(--defaultBackgroundColor)
+
+ .bar-search-input-group-append
+ padding-left .5em
+ color var(--secondaryTextColor)
+
+ input
+ padding-left .5em
+ background-color transparent
+ max-width 100%
+ height 100%
+
+.suggestion-item-parent-link
+ color var(--secondaryTextColor)
+ text-decoration none
+
+ &:hover
+ text-decoration underline
diff --git a/src/modules/search/hooks/useSearch.jsx b/src/modules/search/hooks/useSearch.jsx
new file mode 100644
index 0000000000..95134f835b
--- /dev/null
+++ b/src/modules/search/hooks/useSearch.jsx
@@ -0,0 +1,90 @@
+import { useState, useEffect, useMemo } from 'react'
+
+import { useClient } from 'cozy-client'
+
+import useDebounce from 'hooks/useDebounce'
+import { indexFiles } from 'modules/search/components/helpers'
+
+const useSearch = (searchTerm, { limit = 10 } = {}) => {
+ const client = useClient()
+ const [allSuggestions, setAllSuggestions] = useState([])
+ const [suggestions, setSuggestions] = useState([])
+ const [fuzzy, setFuzzy] = useState(null)
+ const [isBusy, setBusy] = useState(true)
+ const [query, setQuery] = useState('')
+
+ const debouncedSearchTerm = useDebounce(searchTerm, {
+ delay: 500,
+ ignore: searchTerm === ''
+ })
+
+ const makeIndexes = async () => {
+ if (fuzzy == null) {
+ setFuzzy(await indexFiles(client))
+ }
+ }
+
+ useEffect(() => {
+ const fetchSuggestions = async value => {
+ setBusy(true)
+ let currentFuzzy = fuzzy
+ if (currentFuzzy == null) {
+ currentFuzzy = await indexFiles(client)
+ setFuzzy(currentFuzzy)
+ }
+ const suggestions = currentFuzzy.search(value).map(result => ({
+ id: result.id,
+ title: result.name,
+ subtitle: result.path,
+ url: result.url,
+ parentUrl: result.parentUrl,
+ openOn: result.openOn,
+ type: result.type,
+ mime: result.mime,
+ isEncrypted: result.isEncrypted,
+ class: result.class
+ }))
+
+ setBusy(value === '') // To prevent empty state to appear at the first search
+ setQuery(value)
+ setAllSuggestions(suggestions)
+ setSuggestions(suggestions.slice(0, limit))
+ }
+
+ if (debouncedSearchTerm !== '') {
+ fetchSuggestions(debouncedSearchTerm)
+ } else {
+ clearSuggestions()
+ }
+ }, [client, debouncedSearchTerm, fuzzy, limit])
+
+ const hasSuggestions = useMemo(() => suggestions.length > 0, [suggestions])
+
+ const hasMore = useMemo(
+ () => suggestions.length < allSuggestions.length,
+ [suggestions, allSuggestions]
+ )
+
+ const fetchMore = async () => {
+ setSuggestions(allSuggestions.slice(0, suggestions.length + limit))
+ }
+
+ const clearSuggestions = () => {
+ setBusy(true)
+ setQuery('')
+ setAllSuggestions([])
+ setSuggestions([])
+ }
+
+ return {
+ suggestions,
+ hasSuggestions,
+ hasMore,
+ isBusy,
+ query,
+ makeIndexes,
+ fetchMore
+ }
+}
+
+export default useSearch
diff --git a/src/modules/views/Search/SearchView.jsx b/src/modules/views/Search/SearchView.jsx
new file mode 100644
index 0000000000..3828f954fd
--- /dev/null
+++ b/src/modules/views/Search/SearchView.jsx
@@ -0,0 +1,145 @@
+import cx from 'classnames'
+import React, { useState, useCallback } from 'react'
+import { useEffect } from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import { BarLeft, BarSearch } from 'cozy-bar'
+import { models, useClient } from 'cozy-client'
+import { isFlagshipApp } from 'cozy-device-helper'
+import { useWebviewIntent } from 'cozy-intent'
+import Input from 'cozy-ui/transpiled/react/Input'
+import { Main } from 'cozy-ui/transpiled/react/Layout'
+import List from 'cozy-ui/transpiled/react/List'
+import LoadMore from 'cozy-ui/transpiled/react/LoadMore'
+import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
+import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n'
+
+import BackButton from 'components/Button/BackButton'
+import BarSearchInputGroup from 'modules/search/components/BarSearchInputGroup'
+import SearchEmpty from 'modules/search/components/SearchEmpty'
+import SuggestionItem from 'modules/search/components/SuggestionItem'
+import SuggestionListSkeleton from 'modules/search/components/SuggestionListSkeleton'
+import useSearch from 'modules/search/hooks/useSearch'
+
+import styles from 'modules/search/components/styles.styl'
+
+const SearchView = () => {
+ const webviewIntent = useWebviewIntent()
+ const { search } = useLocation()
+ const navigate = useNavigate()
+ const { isMobile } = useBreakpoints()
+ const client = useClient()
+
+ const [searchTerm, setSearchTerm] = useState('')
+ const { t } = useI18n()
+ const {
+ isBusy,
+ suggestions,
+ hasSuggestions,
+ query,
+ makeIndexes,
+ hasMore,
+ fetchMore
+ } = useSearch(searchTerm, { limit: 25 })
+
+ useEffect(() => {
+ makeIndexes()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const onInputChanged = event => {
+ setSearchTerm(event.target.value)
+ }
+
+ const navigateBack = useCallback(() => {
+ const params = new URLSearchParams(search)
+ const returnPath = params.get('returnPath')
+ navigate(returnPath ? returnPath : '/')
+ }, [navigate, search])
+
+ const openSuggestion = useCallback(
+ async suggestion => {
+ if (suggestion.openOn === 'drive') {
+ navigate(suggestion.url)
+ } else if (suggestion.openOn === 'notes') {
+ const url = await models.note.fetchURL(client, {
+ id: suggestion.url.substr(3)
+ })
+ if (isFlagshipApp()) {
+ webviewIntent.call('openApp', url, {
+ slug: 'notes'
+ })
+ } else {
+ window.location.assign(url)
+ }
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(
+ `openSuggestion (${suggestion.name}) could not be executed`
+ )
+ }
+ },
+ [navigate, webviewIntent, client]
+ )
+
+ const handleCleanInput = () => {
+ setSearchTerm('')
+ }
+
+ const hasNoSearchResult = searchTerm !== '' && !hasSuggestions
+
+ return (
+
+ {isMobile && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {hasSuggestions && (
+
+ {suggestions.map(suggestion => (
+
+ ))}
+
+ )}
+ {hasMore && (
+
+
+
+ )}
+ {hasNoSearchResult && !isBusy && }
+ {hasNoSearchResult && isBusy && }
+
+ )
+}
+
+export default SearchView