Merge pull request #53662 from nextcloud/feat/search-in-files

feat(files): allow to proper search in files
This commit is contained in:
Ferdinand Thiessen 2025-07-01 21:35:50 +02:00 committed by GitHub
commit c89856b2fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 1265 additions and 282 deletions

View file

@ -19,7 +19,7 @@ const recentView = {
describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-in-files-recent')
expect(action.id).toBe('open-in-files')
expect(action.displayName([], recentView)).toBe('Open in Files')
expect(action.iconSvgInline([], recentView)).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)

View file

@ -2,19 +2,21 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t } from '@nextcloud/l10n'
import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files'
/**
* TODO: Move away from a redirect and handle
* navigation straight out of the recent view
*/
import type { Node } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { FileType, FileAction, DefaultType } from '@nextcloud/files'
import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search'
export const action = new FileAction({
id: 'open-in-files-recent',
id: 'open-in-files',
displayName: () => t('files', 'Open in Files'),
iconSvgInline: () => '',
enabled: (nodes, view) => view.id === 'recent',
enabled(nodes, view) {
return view.id === 'recent' || view.id === SEARCH_VIEW_ID
},
async exec(node: Node) {
let dir = node.dirname

View file

@ -89,7 +89,7 @@ export default defineComponent({
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
.filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
}
return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
return this.filterVisible(this.views[this.parent.id] ?? [])
},
style() {
@ -103,11 +103,15 @@ export default defineComponent({
},
methods: {
filterVisible(views: View[]) {
return views.filter(({ _view, id }) => id === this.currentView?.id || _view.hidden !== true)
},
hasChildViews(view: View): boolean {
if (this.level >= maxLevel) {
return false
}
return this.views[view.id]?.length > 0
return this.filterVisible(this.views[view.id] ?? []).length > 0
},
/**

View file

@ -0,0 +1,122 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { mdiMagnify, mdiSearchWeb } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useFilesStore } from '../store/files.ts'
import { useSearchStore } from '../store/search.ts'
import { VIEW_ID } from '../views/search.ts'
const { currentView } = useNavigation(true)
const { directory } = useRouteParameters()
const filesStore = useFilesStore()
const searchStore = useSearchStore()
/**
* When the route is changed from search view to something different
* we need to clear the search box.
*/
onBeforeNavigation((to, from, next) => {
if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) {
// we are leaving the search view so unset the query
searchStore.query = ''
searchStore.scope = 'filter'
} else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) {
// fix the query if the user refreshed the view
if (searchStore.query && !to.query.query) {
// @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3)
return next({
...to,
query: {
...to.query,
query: searchStore.query,
},
})
}
}
next()
})
/**
* Are we currently on the search view.
* Needed to disable the action menu (we cannot change the search mode there)
*/
const isSearchView = computed(() => currentView.value.id === VIEW_ID)
/**
* Local search is only possible on real DAV resources within the files root
*/
const canSearchLocally = computed(() => {
if (searchStore.base) {
return true
}
const folder = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
return folder?.isDavResource && folder?.root?.startsWith('/files/')
})
/**
* Different searchbox label depending if filtering or searching
*/
const searchLabel = computed(() => {
if (searchStore.scope === 'globally') {
return t('files', 'Search globally by filename …')
} else if (searchStore.scope === 'locally') {
return t('files', 'Search here by filename …')
}
return t('files', 'Filter file names …')
})
/**
* Update the search value and set the base if needed
* @param value - The new value
*/
function onUpdateSearch(value: string) {
if (searchStore.scope === 'locally' && currentView.value.id !== VIEW_ID) {
searchStore.base = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
}
searchStore.query = value
}
</script>
<template>
<NcAppNavigationSearch :label="searchLabel" :model-value="searchStore.query" @update:modelValue="onUpdateSearch">
<template #actions>
<NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView">
<template #icon>
<NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" />
</template>
<NcActionButton close-after-click @click="searchStore.scope = 'filter'">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
{{ t('files', 'Filter in current view') }}
</NcActionButton>
<NcActionButton v-if="canSearchLocally" close-after-click @click="searchStore.scope = 'locally'">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
{{ t('files', 'Search from this location') }}
</NcActionButton>
<NcActionButton close-after-click @click="searchStore.scope = 'globally'">
<template #icon>
<NcIconSvgWrapper :path="mdiSearchWeb" />
</template>
{{ t('files', 'Search globally') }}
</NcActionButton>
</NcActions>
</template>
</NcAppNavigationSearch>
</template>

View file

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { NavigationGuard } from 'vue-router'
import { onUnmounted } from 'vue'
import { useRouter } from 'vue-router/composables'
/**
* Helper until we use Vue-Router v4 (Vue3).
*
* @param fn - The navigation guard
*/
export function onBeforeNavigation(fn: NavigationGuard) {
const router = useRouter()
const remove = router.beforeResolve(fn)
onUnmounted(remove)
}

View file

@ -1,47 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files'
import { watchThrottled } from '@vueuse/core'
import { onMounted, onUnmounted, ref } from 'vue'
import { FilenameFilter } from '../filters/FilenameFilter'
/**
* This is for the `Navigation` component to provide a filename filter
*/
export function useFilenameFilter() {
const searchQuery = ref('')
const filenameFilter = new FilenameFilter()
/**
* Updating the search query ref from the filter
* @param event The update:query event
*/
function updateQuery(event: CustomEvent) {
if (event.type === 'update:query') {
searchQuery.value = event.detail
event.stopPropagation()
}
}
onMounted(() => {
filenameFilter.addEventListener('update:query', updateQuery)
registerFileListFilter(filenameFilter)
})
onUnmounted(() => {
filenameFilter.removeEventListener('update:query', updateQuery)
unregisterFileListFilter(filenameFilter.id)
})
// Update the query on the filter, but throttle to max. every 800ms
// This will debounce the filter refresh
watchThrottled(searchQuery, () => {
filenameFilter.updateQuery(searchQuery.value)
}, { throttle: 800 })
return {
searchQuery,
}
}

View file

@ -2,7 +2,9 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListFilter, Node } from '@nextcloud/files'
import type { IFileListFilter, Node, View } from '@nextcloud/files'
import type { SearchScope } from './types'
declare module '@nextcloud/event-bus' {
export interface NextcloudEvents {
@ -13,8 +15,13 @@ declare module '@nextcloud/event-bus' {
'files:favorites:removed': Node
'files:favorites:added': Node
'files:filter:added': IFileListFilter
'files:filter:removed': string
// the state of some filters has changed
'files:filters:changed': undefined
'files:navigation:changed': View
'files:node:created': Node
'files:node:deleted': Node
'files:node:updated': Node
@ -22,8 +29,7 @@ declare module '@nextcloud/event-bus' {
'files:node:renamed': Node
'files:node:moved': { node: Node, oldSource: string }
'files:filter:added': IFileListFilter
'files:filter:removed': string
'files:search:updated': { query: string, scope: SearchScope }
}
}

View file

@ -4,17 +4,31 @@
*/
import type { IFileListFilterChip, INode } from '@nextcloud/files'
import { FileListFilter } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
/**
* Register the filename filter
*/
export function registerFilenameFilter() {
registerFileListFilter(new FilenameFilter())
}
/**
* Simple file list filter controlled by the Navigation search box
*/
export class FilenameFilter extends FileListFilter {
class FilenameFilter extends FileListFilter {
private searchQuery = ''
constructor() {
super('files:filename', 5)
subscribe('files:search:updated', ({ query, scope }) => {
if (scope === 'filter') {
this.updateQuery(query)
}
})
}
public filter(nodes: INode[]): INode[] {

View file

@ -26,13 +26,16 @@ 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'
import { isPublicShare } from '@nextcloud/sharing/public'
import { registerConvertActions } from './actions/convertAction.ts'
import { registerFilenameFilter } from './filters/FilenameFilter.ts'
// Register file actions
registerConvertActions()
@ -56,8 +59,9 @@ registerTemplateEntries()
if (isPublicShare() === false) {
registerFavoritesView()
registerFilesView()
registerRecentView()
registerPersonalFilesView()
registerRecentView()
registerSearchView()
registerFolderTreeView()
}
@ -65,6 +69,7 @@ if (isPublicShare() === false) {
registerHiddenFilesFilter()
registerTypeFilter()
registerModifiedFilter()
registerFilenameFilter()
// Register preview service worker
registerPreviewServiceWorker()

View file

@ -11,7 +11,6 @@ import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
import Vue from 'vue'
import { useFilesStore } from '../store/files'
import { useNavigation } from '../composables/useNavigation'
import { usePathsStore } from '../store/paths'
import logger from '../logger'
@ -74,14 +73,27 @@ const router = new Router({
},
})
// Handle aborted navigation (NavigationGuards) gracefully
router.onError((error) => {
if (isNavigationFailure(error, NavigationFailureType.aborted)) {
logger.debug('Navigation was aboorted', { error })
} else {
throw error
}
})
// If navigating back from a folder to a parent folder,
// we need to keep the current dir fileid so it's highlighted
// and scrolled into view.
router.beforeEach((to, from, next) => {
router.beforeResolve((to, from, next) => {
if (to.params?.parentIntercept) {
delete to.params.parentIntercept
next()
return
return next()
}
if (to.params.view !== from.params.view) {
// skip if different views
return next()
}
const fromDir = (from.query?.dir || '/') as string
@ -89,17 +101,16 @@ router.beforeEach((to, from, next) => {
// We are going back to a parent directory
if (relative(fromDir, toDir) === '..') {
const { currentView } = useNavigation()
const { getNode } = useFilesStore()
const { getPath } = usePathsStore()
if (!currentView.value?.id) {
if (!from.params.view) {
logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir })
return next()
}
// Get the previous parent's file id
const fromSource = getPath(currentView.value?.id, fromDir)
const fromSource = getPath(from.params.view, fromDir)
if (!fromSource) {
logger.error('No source found for the parent directory', { fromDir, toDir })
return next()
@ -112,7 +123,7 @@ router.beforeEach((to, from, next) => {
}
logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId })
next({
return next({
name: 'filelist',
query: to.query,
params: {

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

@ -54,13 +54,13 @@ export const useFilesStore = function(...args) {
actions: {
/**
* Get cached child nodes within a given path
* Get cached directory matching a given path
*
* @param service The service (files view)
* @param path The path relative within the service
* @return Array of cached nodes within the path
* @param service - The service (files view)
* @param path - The path relative within the service
* @return The folder if found
*/
getNodesByPath(service: string, path?: string): Node[] {
getDirectoryByPath(service: string, path?: string): Folder | undefined {
const pathsStore = usePathsStore()
let folder: Folder | undefined
@ -74,6 +74,19 @@ export const useFilesStore = function(...args) {
}
}
return folder
},
/**
* Get cached child nodes within a given path
*
* @param service - The service (files view)
* @param path - The path relative within the service
* @return Array of cached nodes within the path
*/
getNodesByPath(service: string, path?: string): Node[] {
const folder = this.getDirectoryByPath(service, path)
// If we found a cache entry and the cache entry was already loaded (has children) then use it
return (folder?._children ?? [])
.map((source: string) => this.getNode(source))

View file

@ -0,0 +1,170 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode, View } from '@nextcloud/files'
import type RouterService from '../services/RouterService'
import type { SearchScope } from '../types'
import { emit, subscribe } from '@nextcloud/event-bus'
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { VIEW_ID } from '../views/search'
import logger from '../logger'
import debounce from 'debounce'
export const useSearchStore = defineStore('search', () => {
/**
* The current search query
*/
const query = ref('')
/**
* Where to start the search
*/
const base = ref<INode>()
/**
* Scope of the search.
* Scopes:
* - filter: only filter current file list
* - locally: search from current location recursivly
* - globally: search everywhere
*/
const scope = ref<SearchScope>('filter')
// reset the base if query is cleared
watch(scope, () => {
if (scope.value !== 'locally') {
base.value = undefined
}
updateSearch()
})
watch(query, (old, current) => {
// skip if only whitespaces changed
if (old.trim() === current.trim()) {
return
}
updateSearch()
})
// initialize the search store
initialize()
/**
* Debounced update of the current route
* @private
*/
const updateRouter = debounce((isSearch: boolean, fileid?: number) => {
const router = window.OCP.Files.Router as RouterService
router.goToRoute(
undefined,
{
view: VIEW_ID,
...(fileid === undefined ? {} : { fileid: String(fileid) }),
},
{
query: query.value,
},
isSearch,
)
})
/**
* Handle updating the filter if needed.
* Also update the search view by updating the current route if needed.
*
* @private
*/
function updateSearch() {
// emit the search event to update the filter
emit('files:search:updated', { query: query.value, scope: scope.value })
const router = window.OCP.Files.Router as RouterService
// if we are on the search view and the query was unset or scope was set to 'filter' we need to move back to the files view
if (router.params.view === VIEW_ID && (query.value === '' || scope.value === 'filter')) {
scope.value = 'filter'
return router.goToRoute(
undefined,
{
view: 'files',
},
{
...router.query,
query: undefined,
},
)
}
// for the filter scope we do not need to adjust the current route anymore
// also if the query is empty we do not need to do anything
if (scope.value === 'filter' || !query.value) {
return
}
// we only use the directory if we search locally
const fileid = scope.value === 'locally' ? base.value?.fileid : undefined
const isSearch = router.params.view === VIEW_ID
logger.debug('Update route for updated search query', { query: query.value, fileid, isSearch })
updateRouter(isSearch, fileid)
}
/**
* Event handler that resets the store if the file list view was changed.
*
* @param view - The new view that is active
* @private
*/
function onViewChanged(view: View) {
if (view.id !== VIEW_ID) {
query.value = ''
scope.value = 'filter'
}
}
/**
* Initialize the store from the router if needed
*/
function initialize() {
subscribe('files:navigation:changed', onViewChanged)
const router = window.OCP.Files.Router as RouterService
// if we initially load the search view (e.g. hard page refresh)
// then we need to initialize the store from the router
if (router.params.view === VIEW_ID) {
query.value = [router.query.query].flat()[0] ?? ''
if (query.value) {
scope.value = 'globally'
logger.debug('Directly navigated to search view', { query: query.value })
} else {
// we do not have any query so we need to move to the files list
logger.info('Directly navigated to search view without any query, redirect to files view.')
router.goToRoute(
undefined,
{
...router.params,
view: 'files',
},
{
...router.query,
query: undefined,
},
true,
)
}
}
}
return {
base,
query,
scope,
}
})

View file

@ -111,6 +111,11 @@ export interface ActiveStore {
activeAction: FileAction|null
}
/**
* Search scope for the in-files-search
*/
export type SearchScope = 'filter'|'locally'|'globally'
export interface TemplateFile {
app: string
label: string

View file

@ -160,6 +160,7 @@ import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
import { ShareType } from '@nextcloud/sharing'
import { UploadPicker, UploadStatus } from '@nextcloud/upload'
import { loadState } from '@nextcloud/initial-state'
import { useThrottleFn } from '@vueuse/core'
import { defineComponent } from 'vue'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
@ -325,16 +326,7 @@ export default defineComponent({
return
}
if (this.directory === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
const source = this.pathsStore.getPath(this.currentView.id, this.directory)
if (source === undefined) {
return
}
return this.filesStore.getNode(source) as Folder
return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory)
},
dirContents(): Node[] {
@ -479,6 +471,10 @@ export default defineComponent({
const hidden = this.dirContents.length - this.dirContentsFiltered.length
return getSummaryFor(this.dirContentsFiltered, hidden)
},
debouncedFetchContent() {
return useThrottleFn(this.fetchContent, 800, true)
},
},
watch: {
@ -540,14 +536,16 @@ export default defineComponent({
// filter content if filter were changed
subscribe('files:filters:changed', this.filterDirContent)
subscribe('files:search:updated', this.onUpdateSearch)
// Finally, fetch the current directory contents
await this.fetchContent()
if (this.fileId) {
// If we have a fileId, let's check if the file exists
const node = this.dirContents.find(node => node.fileid.toString() === this.fileId.toString())
const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString())
// If the file isn't in the current directory nor if
// the current directory is the file, we show an error
if (!node && this.currentFolder.fileid.toString() !== this.fileId.toString()) {
if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) {
showError(t('files', 'The file could not be found'))
}
}
@ -557,9 +555,17 @@ export default defineComponent({
unsubscribe('files:node:deleted', this.onNodeDeleted)
unsubscribe('files:node:updated', this.onUpdatedNode)
unsubscribe('files:config:updated', this.fetchContent)
unsubscribe('files:filters:changed', this.filterDirContent)
unsubscribe('files:search:updated', this.onUpdateSearch)
},
methods: {
onUpdateSearch({ query, scope }) {
if (query && scope !== 'filter') {
this.debouncedFetchContent()
}
},
async fetchContent() {
this.loading = true
this.error = null

View file

@ -10,7 +10,8 @@ import NavigationView from './Navigation.vue'
import { useViewConfigStore } from '../store/viewConfig'
import { Folder, View, getNavigation } from '@nextcloud/files'
import router from '../router/router'
import router from '../router/router.ts'
import RouterService from '../services/RouterService'
const resetNavigation = () => {
const nav = getNavigation()
@ -27,9 +28,18 @@ const createView = (id: string, name: string, parent?: string) => new View({
parent,
})
function mockWindow() {
window.OCP ??= {}
window.OCP.Files ??= {}
window.OCP.Files.Router = new RouterService(router)
}
describe('Navigation renders', () => {
before(() => {
before(async () => {
delete window._nc_navigation
mockWindow()
getNavigation().register(createView('files', 'Files'))
await router.replace({ name: 'filelist', params: { view: 'files' } })
cy.mockInitialState('files', 'storageStats', {
used: 1000 * 1000 * 1000,
@ -41,6 +51,7 @@ describe('Navigation renders', () => {
it('renders', () => {
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@ -60,6 +71,7 @@ describe('Navigation API', () => {
before(async () => {
delete window._nc_navigation
Navigation = getNavigation()
mockWindow()
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
@ -152,14 +164,18 @@ describe('Navigation API', () => {
})
describe('Quota rendering', () => {
before(() => {
before(async () => {
delete window._nc_navigation
mockWindow()
getNavigation().register(createView('files', 'Files'))
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@ -177,6 +193,7 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@ -197,6 +214,7 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@ -219,6 +237,7 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,

View file

@ -7,7 +7,7 @@
class="files-navigation"
:aria-label="t('files', 'Files')">
<template #search>
<NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter file names …')" />
<FilesNavigationSearch />
</template>
<template #default>
<NcAppNavigationList class="files-navigation__list"
@ -39,24 +39,24 @@
</template>
<script lang="ts">
import { getNavigation, type View } from '@nextcloud/files'
import type { View } from '@nextcloud/files'
import type { ViewConfig } from '../types.ts'
import { defineComponent } from 'vue'
import { emit, subscribe } from '@nextcloud/event-bus'
import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { getNavigation } from '@nextcloud/files'
import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import IconCog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
import FilesNavigationItem from '../components/FilesNavigationItem.vue'
import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
import { useNavigation } from '../composables/useNavigation'
import { useFilenameFilter } from '../composables/useFilenameFilter'
import { useFiltersStore } from '../store/filters.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.ts'
@ -75,12 +75,12 @@ export default defineComponent({
components: {
IconCog,
FilesNavigationItem,
FilesNavigationSearch,
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationList,
NcAppNavigationSearch,
SettingsModal,
},
@ -88,11 +88,9 @@ export default defineComponent({
const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
const { searchQuery } = useFilenameFilter()
return {
currentView,
searchQuery,
t,
views,

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

View file

@ -29,7 +29,7 @@ const invalidViews = [
describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-in-files')
expect(action.id).toBe('files_sharing:open-in-files')
expect(action.displayName([], validViews[0])).toBe('Open in Files')
expect(action.iconSvgInline([], validViews[0])).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)

View file

@ -2,15 +2,15 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares'
export const action = new FileAction({
id: 'open-in-files',
id: 'files_sharing:open-in-files',
displayName: () => t('files_sharing', 'Open in Files'),
iconSvgInline: () => '',

View file

@ -0,0 +1,198 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
import { FilesNavigationPage } from '../../pages/FilesNavigation'
import { getRowForFile, navigateToFolder } from './FilesUtils'
describe('files: search', () => {
let user: User
const navigation = new FilesNavigationPage()
before(() => {
cy.createRandomUser().then(($user) => {
user = $user
cy.mkdir(user, '/some 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', '/other folder/another file.txt')
cy.login(user)
})
})
beforeEach(() => {
cy.visit('/apps/files')
})
it('updates the query on the URL', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()
.should('be.visible')
.findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
navigation.searchInput().type('file')
cy.url().should('match', /query=file($|&)/)
})
it('can search globally', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()
.should('be.visible')
.findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
navigation.searchInput().type('file')
getRowForFile('file.txt').should('be.visible')
getRowForFile('a file.txt').should('be.visible')
getRowForFile('a second file.txt').should('be.visible')
getRowForFile('another file.txt').should('be.visible')
})
it('can 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)
})
it('shows empty content when there are no results', () => {
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
cy.contains('[role="note"]', /No search results for .folder./)
.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')
})
})
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()
.should('be.visible')
.findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
navigation.searchInput().type('other')
getRowForFile('another file.txt').should('be.visible')
getRowForFile('other folder').should('be.visible')
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
navigation.searchInput().type(' file')
navigation.searchInput().should('have.value', 'other file')
getRowForFile('another file.txt').should('be.visible')
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
})
it('returns to file list if search is cleared', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()
.should('be.visible')
.findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
navigation.searchInput().type('other')
getRowForFile('another file.txt').should('be.visible')
getRowForFile('other folder').should('be.visible')
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
navigation.searchClearButton().click()
navigation.searchInput().should('have.value', '')
getRowForFile('file.txt').should('be.visible')
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5)
})
/**
* Problem:
* 1. Being on the search view
* 2. Press the refresh button (name of the current view)
* 3. See that the router link does not preserve the query
*
* We fix this with a navigation guard and need to verify that it works
*/
it('keeps the query in the URL', () => {
navigation.searchScopeTrigger().click()
navigation.searchScopeMenu()
.should('be.visible')
.findByRole('menuitem', { name: /search globally/i })
.should('be.visible')
.click()
navigation.searchInput().type('file')
// see that the search view is loaded
getRowForFile('a file.txt').should('be.visible')
// see the correct url
cy.url().should('match', /query=file($|&)/)
cy.intercept('SEARCH', '**/remote.php/dav/').as('search')
// refresh the view
cy.findByRole('button', { description: /reload current directory/i }).click()
// wait for the request
cy.wait('@search')
// see that the search view is reloaded
getRowForFile('a file.txt').should('be.visible')
// see the correct url
cy.url().should('match', /query=file($|&)/)
})
})

View file

@ -13,7 +13,18 @@ export class FilesNavigationPage {
}
searchInput() {
return this.navigation().findByRole('searchbox', { name: /filter file names/i })
return this.navigation().findByRole('searchbox')
}
searchScopeTrigger() {
return this.navigation().findByRole('button', { name: /search scope options/i })
}
/**
* Only available after clicking on the search scope trigger
*/
searchScopeMenu() {
return cy.findByRole('menu', { name: /search scope options/i })
}
searchClearButton() {

2
dist/175-175.js vendored Normal file
View file

@ -0,0 +1,2 @@
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[175],{50540:(e,t,n)=>{n.d(t,{A:()=>l});var s=n(71354),a=n.n(s),r=n(76314),c=n.n(r)()(a());c.push([e.id,".search-empty-view__input[data-v-61d86e6e]{flex:0 1;min-width:min(400px,50vw)}.search-empty-view__wrapper[data-v-61d86e6e]{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline}","",{version:3,sources:["webpack://./apps/files/src/views/SearchEmptyView.vue"],names:[],mappings:"AAEC,2CACC,QAAA,CACA,yBAAA,CAGD,6CACC,YAAA,CACA,cAAA,CACA,QAAA,CACA,oBAAA",sourcesContent:["\n.search-empty-view {\n\t&__input {\n\t\tflex: 0 1;\n\t\tmin-width: min(400px, 50vw);\n\t}\n\n\t&__wrapper {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 10px;\n\t\talign-items: baseline;\n\t}\n}\n"],sourceRoot:""}]);const l=c},60175:(e,t,n)=>{n.r(t),n.d(t,{default:()=>q});var s=n(85471),a=n(9165),r=n(53334),c=n(17334),l=n.n(c),p=n(97012),o=n(32190),i=n(6695),u=n(16879),A=n(4114),d=n(82736);const y=(0,s.pM)({__name:"SearchEmptyView",setup(e){const t=(0,d.j)((0,A.u)()),n=l()((e=>{t.query=e}),500);return{__sfc:!0,searchStore:t,debouncedUpdate:n,mdiMagnifyClose:a.WBH,t:r.t,NcButton:p.A,NcEmptyContent:o.A,NcIconSvgWrapper:i.A,NcInputField:u.A}}});var f=n(85072),m=n.n(f),h=n(97825),C=n.n(h),_=n(77659),v=n.n(_),w=n(55056),x=n.n(w),S=n(10540),b=n.n(S),g=n(41113),N=n.n(g),k=n(50540),E={};E.styleTagTransform=N(),E.setAttributes=x(),E.insert=v().bind(null,"head"),E.domAPI=C(),E.insertStyleElement=b(),m()(k.A,E),k.A&&k.A.locals&&k.A.locals;const q=(0,n(14486).A)(y,(function(){var e=this,t=e._self._c,n=e._self._setupProxy;return t(n.NcEmptyContent,{attrs:{name:n.t("files","No search results for “{query}”",{query:n.searchStore.query})},scopedSlots:e._u([{key:"icon",fn:function(){return[t(n.NcIconSvgWrapper,{attrs:{path:n.mdiMagnifyClose}})]},proxy:!0},{key:"action",fn:function(){return[t("div",{staticClass:"search-empty-view__wrapper"},[t(n.NcInputField,{staticClass:"search-empty-view__input",attrs:{label:n.t("files","Search for files"),"model-value":n.searchStore.query,type:"search"},on:{"update:model-value":n.debouncedUpdate}}),e._v(" "),"locally"===n.searchStore.scope?t(n.NcButton,{on:{click:function(e){n.searchStore.scope="globally"}}},[e._v("\n\t\t\t\t"+e._s(n.t("files","Search globally"))+"\n\t\t\t")]):e._e()],1)]},proxy:!0}])})}),[],!1,null,"61d86e6e",null).exports}}]);
//# sourceMappingURL=175-175.js.map?v=a61155e5a3ee1e107813

121
dist/175-175.js.license vendored Normal file
View file

@ -0,0 +1,121 @@
SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: Apache-2.0
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: inherits developers
SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: debounce developers
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: Roeland Jago Douma
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: Jonas Schade <derzade@gmail.com>
SPDX-FileCopyrightText: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
SPDX-FileCopyrightText: Guillaume Chau
SPDX-FileCopyrightText: GitHub Inc.
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Eduardo San Martin Morote
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: Christoph Wurst <christoph@winzerhof-wurst.at>
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Austin Andrews
SPDX-FileCopyrightText: Alkemics
This file is generated from multiple sources. Included packages:
- @mdi/js
- version: 7.4.47
- license: Apache-2.0
- @mdi/svg
- version: 7.4.47
- license: Apache-2.0
- @nextcloud/auth
- version: 2.5.1
- license: GPL-3.0-or-later
- @nextcloud/browser-storage
- version: 0.4.0
- license: GPL-3.0-or-later
- @nextcloud/capabilities
- version: 1.2.0
- license: GPL-3.0-or-later
- semver
- version: 7.6.3
- license: ISC
- @nextcloud/event-bus
- version: 3.3.2
- license: GPL-3.0-or-later
- @nextcloud/files
- version: 3.10.2
- license: AGPL-3.0-or-later
- @nextcloud/initial-state
- version: 2.2.0
- license: GPL-3.0-or-later
- @nextcloud/l10n
- version: 3.3.0
- license: GPL-3.0-or-later
- @nextcloud/logger
- version: 3.0.2
- license: GPL-3.0-or-later
- @nextcloud/paths
- version: 2.2.1
- license: GPL-3.0-or-later
- @nextcloud/router
- version: 3.0.1
- license: GPL-3.0-or-later
- @nextcloud/sharing
- version: 0.2.4
- license: GPL-3.0-or-later
- @nextcloud/vue
- version: 8.27.0
- license: AGPL-3.0-or-later
- @vue/devtools-api
- version: 6.6.3
- license: MIT
- cancelable-promise
- version: 4.3.1
- license: MIT
- css-loader
- version: 7.1.2
- license: MIT
- debounce
- version: 2.2.0
- license: MIT
- dompurify
- version: 3.2.6
- license: (MPL-2.0 OR Apache-2.0)
- escape-html
- version: 1.0.3
- license: MIT
- inherits
- version: 2.0.3
- license: ISC
- util
- version: 0.10.4
- license: MIT
- path
- version: 0.12.7
- license: MIT
- pinia
- version: 2.3.1
- license: MIT
- process
- version: 0.11.10
- license: MIT
- style-loader
- version: 4.0.0
- license: MIT
- typescript-event-target
- version: 1.1.1
- license: MIT
- vue-loader
- version: 15.11.1
- license: MIT
- vue
- version: 2.7.16
- license: MIT
- nextcloud
- version: 1.0.0
- license: AGPL-3.0-or-later

1
dist/175-175.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/175-175.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
175-175.js.license

2
dist/2210-2210.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/2210-2210.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/2210-2210.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
2210-2210.js.license

2
dist/23-23.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,6 @@ SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Austin Andrews
@ -38,9 +37,3 @@ This file is generated from multiple sources. Included packages:
- style-loader
- version: 4.0.0
- license: MIT
- vue-router
- version: 3.6.5
- license: MIT
- vue
- version: 2.7.16
- license: MIT

1
dist/23-23.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/23-23.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
23-23.js.license

2
dist/4833-4833.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
4833-4833.js.license

4
dist/5810-5810.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/7265-7265.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
7265-7265.js.license

4
dist/7432-7432.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-login.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,7 @@ SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: p-queue developers
SPDX-FileCopyrightText: inherits developers
SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: debounce developers
SPDX-FileCopyrightText: Varun A P
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: Titus Wormer <tituswormer@gmail.com> (https://wooorm.com)
@ -131,6 +132,9 @@ This file is generated from multiple sources. Included packages:
- css-loader
- version: 7.1.2
- license: MIT
- debounce
- version: 2.2.0
- license: MIT
- dompurify
- version: 3.2.6
- license: (MPL-2.0 OR Apache-2.0)

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

View file

@ -53,6 +53,9 @@ This file is generated from multiple sources. Included packages:
- @linusborg/vue-simple-portal
- version: 0.1.5
- license: Apache-2.0
- @mdi/js
- version: 7.4.47
- license: Apache-2.0
- @mdi/svg
- version: 7.4.47
- license: Apache-2.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
(()=>{"use strict";var e,r,t,i={97986:(e,r,t)=>{var i=t(61338),o=t(85168),a=t(63814),n=t(53334);const l=(0,t(35947).YK)().setApp("files").detectUser().build();document.addEventListener("DOMContentLoaded",(function(){const e=window.OCA;e.UnifiedSearch&&(l.info("Initializing unified search plugin: folder search from files app"),e.UnifiedSearch.registerFilterAction({id:"in-folder",appId:"files",searchFrom:"files",label:(0,n.Tl)("files","In folder"),icon:(0,a.d0)("files","app.svg"),callback:function(){arguments.length>0&&void 0!==arguments[0]&&!arguments[0]?l.debug("Folder search callback was handled without showing the file picker, it might already be open"):(0,o.a1)("Pick plain text files").addMimeTypeFilter("httpd/unix-directory").allowDirectories(!0).addButton({label:"Pick",callback:e=>{l.info("Folder picked",{folder:e[0]});const r=e[0];(0,i.Ic)("nextcloud:unified-search:add-filter",{id:"in-folder",appId:"files",searchFrom:"files",payload:r,filterUpdateText:(0,n.Tl)("files","Search in folder: {folder}",{folder:r.basename}),filterParams:{path:r.path}})}}).build().pick()}}))}))}},o={};function a(e){var r=o[e];if(void 0!==r)return r.exports;var t=o[e]={id:e,loaded:!1,exports:{}};return i[e].call(t.exports,t,t.exports,a),t.loaded=!0,t.exports}a.m=i,e=[],a.O=(r,t,i,o)=>{if(!t){var n=1/0;for(s=0;s<e.length;s++){t=e[s][0],i=e[s][1],o=e[s][2];for(var l=!0,d=0;d<t.length;d++)(!1&o||n>=o)&&Object.keys(a.O).every((e=>a.O[e](t[d])))?t.splice(d--,1):(l=!1,o<n&&(n=o));if(l){e.splice(s--,1);var c=i();void 0!==c&&(r=c)}}return r}o=o||0;for(var s=e.length;s>0&&e[s-1][2]>o;s--)e[s]=e[s-1];e[s]=[t,i,o]},a.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return a.d(r,{a:r}),r},a.d=(e,r)=>{for(var t in r)a.o(r,t)&&!a.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((r,t)=>(a.f[t](e,r),r)),[])),a.u=e=>e+"-"+e+".js?v="+{640:"b2fa23a809053c6305c5",5771:"a4e2a98efcfb7393c5bd",5810:"8dfb2392d7107957a510",7432:"126e4e5eedf7af9a92fc"}[e],a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud:",a.l=(e,i,o,n)=>{if(r[e])r[e].push(i);else{var l,d;if(void 0!==o)for(var c=document.getElementsByTagName("script"),s=0;s<c.length;s++){var f=c[s];if(f.getAttribute("src")==e||f.getAttribute("data-webpack")==t+o){l=f;break}}l||(d=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,a.nc&&l.setAttribute("nonce",a.nc),l.setAttribute("data-webpack",t+o),l.src=e),r[e]=[i];var u=(t,i)=>{l.onerror=l.onload=null,clearTimeout(p);var o=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),o&&o.forEach((e=>e(i))),t)return t(i)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),d&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.j=2277,(()=>{var e;a.g.importScripts&&(e=a.g.location+"");var r=a.g.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var i=t.length-1;i>-1&&(!e||!/^http(s?):/.test(e));)e=t[i--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),a.p=e})(),(()=>{a.b=document.baseURI||self.location.href;var e={2277:0};a.f.j=(r,t)=>{var i=a.o(e,r)?e[r]:void 0;if(0!==i)if(i)t.push(i[2]);else{var o=new Promise(((t,o)=>i=e[r]=[t,o]));t.push(i[2]=o);var n=a.p+a.u(r),l=new Error;a.l(n,(t=>{if(a.o(e,r)&&(0!==(i=e[r])&&(e[r]=void 0),i)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",l.name="ChunkLoadError",l.type=o,l.request=n,i[1](l)}}),"chunk-"+r,r)}},a.O.j=r=>0===e[r];var r=(r,t)=>{var i,o,n=t[0],l=t[1],d=t[2],c=0;if(n.some((r=>0!==e[r]))){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(d)var s=d(a)}for(r&&r(t);c<n.length;c++)o=n[c],a.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return a.O(s)},t=self.webpackChunknextcloud=self.webpackChunknextcloud||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),a.nc=void 0;var n=a.O(void 0,[4208],(()=>a(97986)));n=a.O(n)})();
//# sourceMappingURL=files-search.js.map?v=1f9ef908cc982904c4c0
(()=>{"use strict";var e,r,t,i={97986:(e,r,t)=>{var i=t(61338),a=t(85168),o=t(63814),n=t(53334);const l=(0,t(35947).YK)().setApp("files").detectUser().build();document.addEventListener("DOMContentLoaded",(function(){const e=window.OCA;e.UnifiedSearch&&(l.info("Initializing unified search plugin: folder search from files app"),e.UnifiedSearch.registerFilterAction({id:"in-folder",appId:"files",searchFrom:"files",label:(0,n.Tl)("files","In folder"),icon:(0,o.d0)("files","app.svg"),callback:function(){arguments.length>0&&void 0!==arguments[0]&&!arguments[0]?l.debug("Folder search callback was handled without showing the file picker, it might already be open"):(0,a.a1)("Pick plain text files").addMimeTypeFilter("httpd/unix-directory").allowDirectories(!0).addButton({label:"Pick",callback:e=>{l.info("Folder picked",{folder:e[0]});const r=e[0];(0,i.Ic)("nextcloud:unified-search:add-filter",{id:"in-folder",appId:"files",searchFrom:"files",payload:r,filterUpdateText:(0,n.Tl)("files","Search in folder: {folder}",{folder:r.basename}),filterParams:{path:r.path}})}}).build().pick()}}))}))}},a={};function o(e){var r=a[e];if(void 0!==r)return r.exports;var t=a[e]={id:e,loaded:!1,exports:{}};return i[e].call(t.exports,t,t.exports,o),t.loaded=!0,t.exports}o.m=i,e=[],o.O=(r,t,i,a)=>{if(!t){var n=1/0;for(s=0;s<e.length;s++){t=e[s][0],i=e[s][1],a=e[s][2];for(var l=!0,d=0;d<t.length;d++)(!1&a||n>=a)&&Object.keys(o.O).every((e=>o.O[e](t[d])))?t.splice(d--,1):(l=!1,a<n&&(n=a));if(l){e.splice(s--,1);var c=i();void 0!==c&&(r=c)}}return r}a=a||0;for(var s=e.length;s>0&&e[s-1][2]>a;s--)e[s]=e[s-1];e[s]=[t,i,a]},o.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return o.d(r,{a:r}),r},o.d=(e,r)=>{for(var t in r)o.o(r,t)&&!o.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},o.f={},o.e=e=>Promise.all(Object.keys(o.f).reduce(((r,t)=>(o.f[t](e,r),r)),[])),o.u=e=>e+"-"+e+".js?v="+{640:"b2fa23a809053c6305c5",5771:"a4e2a98efcfb7393c5bd",5810:"44e839656fd178ba3292",7432:"bf576075b1d8131aa273"}[e],o.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),o.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud:",o.l=(e,i,a,n)=>{if(r[e])r[e].push(i);else{var l,d;if(void 0!==a)for(var c=document.getElementsByTagName("script"),s=0;s<c.length;s++){var f=c[s];if(f.getAttribute("src")==e||f.getAttribute("data-webpack")==t+a){l=f;break}}l||(d=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,o.nc&&l.setAttribute("nonce",o.nc),l.setAttribute("data-webpack",t+a),l.src=e),r[e]=[i];var u=(t,i)=>{l.onerror=l.onload=null,clearTimeout(p);var a=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),a&&a.forEach((e=>e(i))),t)return t(i)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),d&&document.head.appendChild(l)}},o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),o.j=2277,(()=>{var e;o.g.importScripts&&(e=o.g.location+"");var r=o.g.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var i=t.length-1;i>-1&&(!e||!/^http(s?):/.test(e));)e=t[i--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),o.p=e})(),(()=>{o.b=document.baseURI||self.location.href;var e={2277:0};o.f.j=(r,t)=>{var i=o.o(e,r)?e[r]:void 0;if(0!==i)if(i)t.push(i[2]);else{var a=new Promise(((t,a)=>i=e[r]=[t,a]));t.push(i[2]=a);var n=o.p+o.u(r),l=new Error;o.l(n,(t=>{if(o.o(e,r)&&(0!==(i=e[r])&&(e[r]=void 0),i)){var a=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+a+": "+n+")",l.name="ChunkLoadError",l.type=a,l.request=n,i[1](l)}}),"chunk-"+r,r)}},o.O.j=r=>0===e[r];var r=(r,t)=>{var i,a,n=t[0],l=t[1],d=t[2],c=0;if(n.some((r=>0!==e[r]))){for(i in l)o.o(l,i)&&(o.m[i]=l[i]);if(d)var s=d(o)}for(r&&r(t);c<n.length;c++)a=n[c],o.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return o.O(s)},t=self.webpackChunknextcloud=self.webpackChunknextcloud||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),o.nc=void 0;var n=o.O(void 0,[4208],(()=>o(97986)));n=o.O(n)})();
//# sourceMappingURL=files-search.js.map?v=2ee7887775bf055c14c5

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more