diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-07-04 16:35:08 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-07-07 10:52:21 +0200 |
commit | 3978e056cf530a27503f81f8b8b3013c015b84bd (patch) | |
tree | 95ac08ab96b9b7a2ab16f321d6753b96dfd518ad | |
parent | 1567d622dc04b0a7955340e2ab98b70c4780c46f (diff) | |
download | nextcloud-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.ts | 66 | ||||
-rw-r--r-- | apps/files/src/views/files.ts | 34 | ||||
-rw-r--r-- | cypress/e2e/files/search.cy.ts | 52 |
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() |