mirror of
https://github.com/nextcloud/server.git
synced 2026-03-18 16:45:22 -04:00
feat(files): add search view
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
2521622709
commit
b2d0b4adeb
7 changed files with 308 additions and 4 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
61
apps/files/src/services/Search.spec.ts
Normal file
61
apps/files/src/services/Search.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
44
apps/files/src/services/Search.ts
Normal file
44
apps/files/src/services/Search.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
83
apps/files/src/services/WebDavSearch.ts
Normal file
83
apps/files/src/services/WebDavSearch.ts
Normal 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()))
|
||||
}
|
||||
57
apps/files/src/views/SearchEmptyView.vue
Normal file
57
apps/files/src/views/SearchEmptyView.vue
Normal 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>
|
||||
|
|
@ -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.'),
|
||||
|
||||
|
|
|
|||
51
apps/files/src/views/search.ts
Normal file
51
apps/files/src/views/search.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
Loading…
Reference in a new issue