aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2025-07-04 16:35:08 +0200
committerFerdinand Thiessen <opensource@fthiessen.de>2025-07-07 10:52:21 +0200
commit3978e056cf530a27503f81f8b8b3013c015b84bd (patch)
tree95ac08ab96b9b7a2ab16f321d6753b96dfd518ad
parent1567d622dc04b0a7955340e2ab98b70c4780c46f (diff)
downloadnextcloud-server-3978e056cf530a27503f81f8b8b3013c015b84bd.tar.gz
nextcloud-server-3978e056cf530a27503f81f8b8b3013c015b84bd.zip
feat(files): search locally in the background while filtering by name
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r--apps/files/src/services/Files.ts66
-rw-r--r--apps/files/src/views/files.ts34
-rw-r--r--cypress/e2e/files/search.cy.ts52
3 files changed, 105 insertions, 47 deletions
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
index f02b48f64f3..080ce91e538 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -2,25 +2,55 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ContentsWithRoot, File, Folder } from '@nextcloud/files'
+import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
-import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
+import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { join } from 'path'
import { client } from './WebdavClient.ts'
+import { searchNodes } from './WebDavSearch.ts'
+import { getPinia } from '../store/index.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSearchStore } from '../store/search.ts'
import logger from '../logger.ts'
-
/**
* Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
* @param stat The result returned by the webdav library
*/
-export const resultToNode = (stat: FileStat): File | Folder => davResultToNode(stat)
+export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
-export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
- path = join(davRootPath, path)
+/**
+ * Get contents implementation for the files view.
+ * This also allows to fetch local search results when the user is currently filtering.
+ *
+ * @param path - The path to query
+ */
+export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
const controller = new AbortController()
- const propfindPayload = davGetDefaultPropfind()
+ const searchStore = useSearchStore(getPinia())
+
+ if (searchStore.query.length >= 3) {
+ return new CancelablePromise((resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ getLocalSearch(path, searchStore.query, controller.signal)
+ .then(resolve)
+ .catch(reject)
+ })
+ } else {
+ return defaultGetContents(path)
+ }
+}
+
+/**
+ * Generic `getContents` implementation for the users files.
+ *
+ * @param path - The path to get the contents
+ */
+export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> {
+ path = join(defaultRootPath, path)
+ const controller = new AbortController()
+ const propfindPayload = getDefaultPropfind()
return new CancelablePromise(async (resolve, reject, onCancel) => {
onCancel(() => controller.abort())
@@ -56,3 +86,25 @@ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> =>
}
})
}
+
+/**
+ * Get the local search results for the current folder.
+ *
+ * @param path - The path
+ * @param query - The current search query
+ * @param signal - The aboort signal
+ */
+async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> {
+ const filesStore = useFilesStore(getPinia())
+ let folder = filesStore.getDirectoryByPath('files', path)
+ if (!folder) {
+ const rootPath = join(defaultRootPath, path)
+ const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat>
+ folder = resultToNode(stat.data) as Folder
+ }
+ const contents = await searchNodes(query, { dir: path, signal })
+ return {
+ folder,
+ contents,
+ }
+}
diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts
index 1be58fdaf9a..95450f0d71a 100644
--- a/apps/files/src/views/files.ts
+++ b/apps/files/src/views/files.ts
@@ -3,9 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { emit, subscribe } from '@nextcloud/event-bus'
import { View, getNavigation } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { getContents } from '../services/Files.ts'
+import { useActiveStore } from '../store/active.ts'
import { defaultView } from '../utils/filesViews.ts'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
@@ -16,6 +18,9 @@ export const VIEW_ID = 'files'
* Register the files view to the navigation
*/
export function registerFilesView() {
+ // we cache the query to allow more performant search (see below in event listener)
+ let oldQuery = ''
+
const Navigation = getNavigation()
Navigation.register(new View({
id: VIEW_ID,
@@ -28,4 +33,33 @@ export function registerFilesView() {
getContents,
}))
+
+ // when the search is updated
+ // and we are in the files view
+ // and there is already a folder fetched
+ // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered
+ subscribe('files:search:updated', ({ scope, query }) => {
+ if (scope === 'globally') {
+ return
+ }
+
+ if (Navigation.active?.id !== VIEW_ID) {
+ return
+ }
+
+ // If neither the old query nor the new query is longer than the search minimum
+ // then we do not need to trigger a new PROPFIND / SEARCH
+ // so we skip unneccessary requests here
+ if (oldQuery.length < 3 && query.length < 3) {
+ return
+ }
+
+ const store = useActiveStore()
+ if (!store.activeFolder) {
+ return
+ }
+
+ oldQuery = query
+ emit('files:node:updated', store.activeFolder)
+ })
}
diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts
index be3a7059382..59c0019acb4 100644
--- a/cypress/e2e/files/search.cy.ts
+++ b/cypress/e2e/files/search.cy.ts
@@ -17,11 +17,13 @@ describe('files: search', () => {
cy.createRandomUser().then(($user) => {
user = $user
cy.mkdir(user, '/some folder')
+ cy.mkdir(user, '/some folder/nested folder')
cy.mkdir(user, '/other folder')
cy.mkdir(user, '/12345')
cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt')
cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt')
cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt')
cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt')
cy.login(user)
})
@@ -58,21 +60,16 @@ describe('files: search', () => {
getRowForFile('another file.txt').should('be.visible')
})
- it('can search locally', () => {
+ it('filter does also search locally', () => {
navigateToFolder('some folder')
getRowForFile('a file.txt').should('be.visible')
- navigation.searchScopeTrigger().click()
- navigation.searchScopeMenu()
- .should('be.visible')
- .findByRole('menuitem', { name: /search from this location/i })
- .should('be.visible')
- .click()
navigation.searchInput().type('file')
getRowForFile('a file.txt').should('be.visible')
getRowForFile('a second file.txt').should('be.visible')
- cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
+ getRowForFile('deep file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3)
})
it('See "search everywhere" button', () => {
@@ -107,7 +104,8 @@ describe('files: search', () => {
// see local results
getRowForFile('a file.txt').should('be.visible')
getRowForFile('a second file.txt').should('be.visible')
- cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
+ getRowForFile('deep file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3)
// toggle global search
cy.get('[data-cy-files-filters]')
@@ -118,6 +116,7 @@ describe('files: search', () => {
// see global results
getRowForFile('file.txt').should('be.visible')
getRowForFile('a file.txt').should('be.visible')
+ getRowForFile('deep file.txt').should('be.visible')
getRowForFile('a second file.txt').should('be.visible')
getRowForFile('another file.txt').should('be.visible')
})
@@ -129,49 +128,22 @@ describe('files: search', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()
.should('be.visible')
- .findByRole('menuitem', { name: /search from this location/i })
+ .findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
- navigation.searchInput().type('folder')
+ navigation.searchInput().type('xyz')
// see the empty content message
- cy.contains('[role="note"]', /No search results for .folder./)
+ cy.contains('[role="note"]', /No search results for .xyz./)
.should('be.visible')
.within(() => {
// see within there is a search box with the same value
cy.findByRole('searchbox', { name: /search for files/i })
.should('be.visible')
- .and('have.value', 'folder')
- // and we can switch from local to global search
- cy.findByRole('button', { name: 'Search globally' })
- .should('be.visible')
+ .and('have.value', 'xyz')
})
})
- it('can turn local search into global search', () => {
- navigateToFolder('some folder')
- getRowForFile('a file.txt').should('be.visible')
-
- navigation.searchScopeTrigger().click()
- navigation.searchScopeMenu()
- .should('be.visible')
- .findByRole('menuitem', { name: /search from this location/i })
- .should('be.visible')
- .click()
- navigation.searchInput().type('folder')
-
- // see the empty content message and turn into global search
- cy.contains('[role="note"]', /No search results for .folder./)
- .should('be.visible')
- .findByRole('button', { name: 'Search globally' })
- .should('be.visible')
- .click()
-
- getRowForFile('some folder').should('be.visible')
- getRowForFile('other folder').should('be.visible')
- cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
- })
-
it('can alter search', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()