From b2d0b4adeb8a0cb19498a6d9727b4bfb6937a1e4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 15:03:30 +0200 Subject: [PATCH] feat(files): add `search` view Signed-off-by: Ferdinand Thiessen --- apps/files/src/init.ts | 7 +- apps/files/src/services/Search.spec.ts | 61 +++++++++++++++++ apps/files/src/services/Search.ts | 44 +++++++++++++ apps/files/src/services/WebDavSearch.ts | 83 ++++++++++++++++++++++++ apps/files/src/views/SearchEmptyView.vue | 57 ++++++++++++++++ apps/files/src/views/files.ts | 9 ++- apps/files/src/views/search.ts | 51 +++++++++++++++ 7 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 apps/files/src/services/Search.spec.ts create mode 100644 apps/files/src/services/Search.ts create mode 100644 apps/files/src/services/WebDavSearch.ts create mode 100644 apps/files/src/views/SearchEmptyView.vue create mode 100644 apps/files/src/views/search.ts diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index e627e31bc98..a9aedb5fb63 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -26,8 +26,10 @@ import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' import { registerFavoritesView } from './views/favorites.ts' import registerRecentView from './views/recent' import registerPersonalFilesView from './views/personal-files' -import registerFilesView from './views/files' +import { registerFilesView } from './views/files' import { registerFolderTreeView } from './views/folderTree.ts' +import { registerSearchView } from './views/search.ts' + import registerPreviewServiceWorker from './services/ServiceWorker.js' import { initLivePhotos } from './services/LivePhotos' @@ -57,8 +59,9 @@ registerTemplateEntries() if (isPublicShare() === false) { registerFavoritesView() registerFilesView() - registerRecentView() registerPersonalFilesView() + registerRecentView() + registerSearchView() registerFolderTreeView() } diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts new file mode 100644 index 00000000000..c2840521a15 --- /dev/null +++ b/apps/files/src/services/Search.spec.ts @@ -0,0 +1,61 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia, setActivePinia } from 'pinia' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { getContents } from './Search.ts' +import { Folder, Permission } from '@nextcloud/files' + +const searchNodes = vi.hoisted(() => vi.fn()) +vi.mock('./WebDavSearch.ts', () => ({ searchNodes })) +vi.mock('@nextcloud/auth') + +describe('Search service', () => { + const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' }) + + beforeAll(() => { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router ??= { params: {}, query: {} } + vi.spyOn(window.OCP.Files.Router, 'params', 'get').mockReturnValue({ view: 'files' }) + }) + + beforeEach(() => { + vi.restoreAllMocks() + setActivePinia(createPinia()) + }) + + it('rejects on error', async () => { + searchNodes.mockImplementationOnce(() => { throw new Error('expected error') }) + expect(getContents).rejects.toThrow('expected error') + }) + + it('returns the search results and a fake root', async () => { + searchNodes.mockImplementationOnce(() => [fakeFolder]) + const { contents, folder } = await getContents() + + expect(searchNodes).toHaveBeenCalledOnce() + expect(contents).toHaveLength(1) + expect(contents).toEqual([fakeFolder]) + // read only root + expect(folder.permissions).toBe(Permission.READ) + }) + + it('can be cancelled', async () => { + const { promise, resolve } = Promise.withResolvers() + searchNodes.mockImplementationOnce(async (_, { signal }: { signal: AbortSignal}) => { + signal.addEventListener('abort', resolve) + await promise + return [] + }) + + const content = getContents() + content.cancel() + + // its cancelled thus the promise returns the event + const event = await promise + expect(event.type).toBe('abort') + }) +}) diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts new file mode 100644 index 00000000000..ae6f1ee50e0 --- /dev/null +++ b/apps/files/src/services/Search.ts @@ -0,0 +1,44 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ContentsWithRoot } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { Folder, Permission } from '@nextcloud/files' +import { defaultRemoteURL } from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' +import { searchNodes } from './WebDavSearch.ts' +import logger from '../logger.ts' +import { useSearchStore } from '../store/search.ts' +import { getPinia } from '../store/index.ts' + +/** + * Get the contents for a search view + */ +export function getContents(): CancelablePromise { + const controller = new AbortController() + + const searchStore = useSearchStore(getPinia()) + const dir = searchStore.base?.path + + return new CancelablePromise(async (resolve, reject, cancel) => { + cancel(() => controller.abort()) + try { + const contents = await searchNodes(searchStore.query, { dir, signal: controller.signal }) + resolve({ + contents, + folder: new Folder({ + id: 0, + source: `${defaultRemoteURL}#search`, + owner: getCurrentUser()!.uid, + permissions: Permission.READ, + }), + }) + } catch (error) { + logger.error('Failed to fetch search results', { error }) + reject(error) + } + }) +} diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts new file mode 100644 index 00000000000..feb7f30b357 --- /dev/null +++ b/apps/files/src/services/WebDavSearch.ts @@ -0,0 +1,83 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { ResponseDataDetailed, SearchResult } from 'webdav' + +import { getCurrentUser } from '@nextcloud/auth' +import { defaultRootPath, getDavNameSpaces, getDavProperties, resultToNode } from '@nextcloud/files/dav' +import { getBaseUrl } from '@nextcloud/router' +import { client } from './WebdavClient.ts' +import logger from '../logger.ts' + +export interface SearchNodesOptions { + dir?: string, + signal?: AbortSignal +} + +/** + * Search for nodes matching the given query. + * + * @param query - Search query + * @param options - Options + * @param options.dir - The base directory to scope the search to + * @param options.signal - Abort signal for the request + */ +export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise { + const user = getCurrentUser() + if (!user) { + // the search plugin only works for user roots + return [] + } + + query = query.trim() + if (query.length < 3) { + // the search plugin only works with queries of at least 3 characters + return [] + } + + if (dir && !dir.startsWith('/')) { + dir = `/${dir}` + } + + logger.debug('Searching for nodes', { query, dir }) + const { data } = await client.search('/', { + details: true, + signal, + data: ` + + + + + ${getDavProperties()} + + + + + /files/${user.uid}${dir || ''} + infinity + + + + + + + + %${query.replace('%', '')}% + + + + +`, + }) as ResponseDataDetailed + + // check if the request was aborted + if (signal?.aborted) { + return [] + } + + // otherwise return the result mapped to Nextcloud nodes + return data.results.map((result) => resultToNode(result, defaultRootPath, getBaseUrl())) +} diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue new file mode 100644 index 00000000000..0553e416caf --- /dev/null +++ b/apps/files/src/views/SearchEmptyView.vue @@ -0,0 +1,57 @@ + + + + + + + diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts index a49a13f91e1..699e173de63 100644 --- a/apps/files/src/views/files.ts +++ b/apps/files/src/views/files.ts @@ -8,10 +8,15 @@ import FolderSvg from '@mdi/svg/svg/folder.svg?raw' import { getContents } from '../services/Files' import { View, getNavigation } from '@nextcloud/files' -export default () => { +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { const Navigation = getNavigation() Navigation.register(new View({ - id: 'files', + id: VIEW_ID, name: t('files', 'All files'), caption: t('files', 'List of your files and folders.'), diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts new file mode 100644 index 00000000000..a30f732163c --- /dev/null +++ b/apps/files/src/views/search.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance' + +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Search.ts' +import { VIEW_ID as FILES_VIEW_ID } from './files.ts' +import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw' +import Vue from 'vue' + +export const VIEW_ID = 'search' + +/** + * Register the search-in-files view + */ +export function registerSearchView() { + let instance: Vue + let view: ComponentPublicInstanceConstructor + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Search'), + caption: t('files', 'Search results within your files.'), + + async emptyView(el) { + if (!view) { + view = (await import('./SearchEmptyView.vue')).default + } else { + instance.$destroy() + } + instance = new Vue(view) + instance.$mount(el) + }, + + icon: MagnifySvg, + order: 10, + + parent: FILES_VIEW_ID, + // it should be shown expanded + expanded: true, + // this view is hidden by default and only shown when active + hidden: true, + + getContents, + })) +}