feat(files): add search view

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-06-24 15:03:30 +02:00
parent 2521622709
commit b2d0b4adeb
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
7 changed files with 308 additions and 4 deletions

View file

@ -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()
}

View file

@ -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<Event>()
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')
})
})

View file

@ -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<ContentsWithRoot> {
const controller = new AbortController()
const searchStore = useSearchStore(getPinia())
const dir = searchStore.base?.path
return new CancelablePromise<ContentsWithRoot>(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)
}
})
}

View file

@ -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<INode[]> {
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: `
<d:searchrequest ${getDavNameSpaces()}>
<d:basicsearch>
<d:select>
<d:prop>
${getDavProperties()}
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/files/${user.uid}${dir || ''}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>%${query.replace('%', '')}%</d:literal>
</d:like>
</d:where>
<d:orderby/>
</d:basicsearch>
</d:searchrequest>`,
}) as ResponseDataDetailed<SearchResult>
// 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()))
}

View file

@ -0,0 +1,57 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { mdiMagnifyClose } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import { getPinia } from '../store/index.ts'
import { useSearchStore } from '../store/search.ts'
const searchStore = useSearchStore(getPinia())
const debouncedUpdate = debounce((value: string) => {
searchStore.query = value
}, 500)
</script>
<template>
<NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnifyClose" />
</template>
<template #action>
<div class="search-empty-view__wrapper">
<NcInputField class="search-empty-view__input"
:label="t('files', 'Search for files')"
:model-value="searchStore.query"
type="search"
@update:model-value="debouncedUpdate" />
<NcButton v-if="searchStore.scope === 'locally'" @click="searchStore.scope = 'globally'">
{{ t('files', 'Search globally') }}
</NcButton>
</div>
</template>
</NcEmptyContent>
</template>
<style scoped lang="scss">
.search-empty-view {
&__input {
flex: 0 1;
min-width: min(400px, 50vw);
}
&__wrapper {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: baseline;
}
}
</style>

View file

@ -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.'),

View file

@ -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,
}))
}