From 28b2710ef011d9c51c6a45650c41fbb5508d529d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 14:52:40 +0200 Subject: fix(files): log aborted navigation as debug level Signed-off-by: Ferdinand Thiessen --- apps/files/src/router/router.ts | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'apps/files/src') diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts index 00f08c38d31..cf0e43c18ca 100644 --- a/apps/files/src/router/router.ts +++ b/apps/files/src/router/router.ts @@ -74,6 +74,15 @@ 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. -- cgit v1.2.3 From 2c3774892ea1e40fa4474ce6b304b4522ebd842b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Jun 2025 19:24:41 +0200 Subject: feat(files): add `getDirectoryByPath` to files store Signed-off-by: Ferdinand Thiessen --- apps/files/src/eventbus.d.ts | 8 ++++++-- apps/files/src/store/files.ts | 23 ++++++++++++++++++----- apps/files/src/views/FilesList.vue | 11 +---------- 3 files changed, 25 insertions(+), 17 deletions(-) (limited to 'apps/files/src') diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index fb61b4a6d03..c9f0fbf86e5 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -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 { SearchScope } from './types' declare module '@nextcloud/event-bus' { export interface NextcloudEvents { @@ -13,6 +15,9 @@ 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:node:created': Node @@ -22,8 +27,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 } } } diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 295704c880b..3591832d0c4 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -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)) diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 60791a2b527..c50f6bb31b6 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -325,16 +325,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[] { -- cgit v1.2.3 From d5a4eb81395c57e655469ccd6f951ab9eb3410d9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Jun 2025 19:27:23 +0200 Subject: fix(files): also use `open-in-files` for the search view Signed-off-by: Ferdinand Thiessen --- apps/files/src/actions/openInFilesAction.spec.ts | 2 +- apps/files/src/actions/openInFilesAction.ts | 18 ++++++++++-------- .../src/files_actions/openInFilesAction.spec.ts | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) (limited to 'apps/files/src') diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts index e732270d4c0..3ccd15fa2d2 100644 --- a/apps/files/src/actions/openInFilesAction.spec.ts +++ b/apps/files/src/actions/openInFilesAction.spec.ts @@ -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) diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts index 10e19e7eace..9e10b1ac74e 100644 --- a/apps/files/src/actions/openInFilesAction.ts +++ b/apps/files/src/actions/openInFilesAction.ts @@ -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 diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts index 95bd2812db7..23c0938545c 100644 --- a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts +++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts @@ -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) -- cgit v1.2.3 From c9997f1e0b08b5bb83df09d544c8e2e7b5c045ba Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 14:59:54 +0200 Subject: feat(files): add `search` store to handle all search related state Signed-off-by: Ferdinand Thiessen --- apps/files/src/eventbus.d.ts | 4 +- apps/files/src/store/search.ts | 170 +++++++++++++++++++++++++++++++++++++++++ apps/files/src/types.ts | 5 ++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 apps/files/src/store/search.ts (limited to 'apps/files/src') diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index c9f0fbf86e5..ab8dbb63dfc 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -3,7 +3,7 @@ * 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' { @@ -20,6 +20,8 @@ declare module '@nextcloud/event-bus' { // 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 diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts new file mode 100644 index 00000000000..286cad253fc --- /dev/null +++ b/apps/files/src/store/search.ts @@ -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() + + /** + * Scope of the search. + * Scopes: + * - filter: only filter current file list + * - locally: search from current location recursivly + * - globally: search everywhere + */ + const scope = ref('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, + } +}) diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index db3de13d4eb..7e9696d31d6 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -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 -- cgit v1.2.3 From 25216227091ae4d7ac721cd16ff00e92c3aa94cc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 15:00:23 +0200 Subject: refactor(files): adjust filename filter to use events Signed-off-by: Ferdinand Thiessen --- apps/files/src/filters/FilenameFilter.ts | 18 ++++++++++++++++-- apps/files/src/init.ts | 2 ++ 2 files changed, 18 insertions(+), 2 deletions(-) (limited to 'apps/files/src') diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts index 5019ca42d83..7914142f6ca 100644 --- a/apps/files/src/filters/FilenameFilter.ts +++ b/apps/files/src/filters/FilenameFilter.ts @@ -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[] { diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 492ffbb1915..e627e31bc98 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -33,6 +33,7 @@ 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() @@ -65,6 +66,7 @@ if (isPublicShare() === false) { registerHiddenFilesFilter() registerTypeFilter() registerModifiedFilter() +registerFilenameFilter() // Register preview service worker registerPreviewServiceWorker() -- cgit v1.2.3 From b2d0b4adeb8a0cb19498a6d9727b4bfb6937a1e4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 15:03:30 +0200 Subject: 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 (limited to 'apps/files/src') 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, + })) +} -- cgit v1.2.3 From 2c65bd2f4b0727bd670cd299a79b0d7fab05ac54 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 15:04:07 +0200 Subject: feat(files): allow hidden views Signed-off-by: Ferdinand Thiessen --- apps/files/src/components/FilesNavigationItem.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'apps/files/src') diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue index 372a83e1441..2c7c8b4b944 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -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 }, /** -- cgit v1.2.3 From 32dfd34099c74234e409b45c9eef6078cb56fa13 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 24 Jun 2025 15:04:34 +0200 Subject: feat(files): add search scope toggle and logic Signed-off-by: Ferdinand Thiessen --- .../files/src/components/FilesNavigationSearch.vue | 122 +++++++++++++++++++++ apps/files/src/composables/useBeforeNavigation.ts | 20 ++++ apps/files/src/composables/useFilenameFilter.ts | 47 -------- apps/files/src/views/Navigation.vue | 16 ++- 4 files changed, 149 insertions(+), 56 deletions(-) create mode 100644 apps/files/src/components/FilesNavigationSearch.vue create mode 100644 apps/files/src/composables/useBeforeNavigation.ts delete mode 100644 apps/files/src/composables/useFilenameFilter.ts (limited to 'apps/files/src') diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..85dc5534e5e --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/apps/files/src/composables/useBeforeNavigation.ts b/apps/files/src/composables/useBeforeNavigation.ts new file mode 100644 index 00000000000..38b72e40fb3 --- /dev/null +++ b/apps/files/src/composables/useBeforeNavigation.ts @@ -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) +} diff --git a/apps/files/src/composables/useFilenameFilter.ts b/apps/files/src/composables/useFilenameFilter.ts deleted file mode 100644 index 54c16f35384..00000000000 --- a/apps/files/src/composables/useFilenameFilter.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 3147268f34d..c424a0d74b8 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -7,7 +7,7 @@ class="files-navigation" :aria-label="t('files', 'Files')">