diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts index 26b827909d4..4aa89e4ea5f 100644 --- a/apps/files/src/services/Favorites.ts +++ b/apps/files/src/services/Favorites.ts @@ -1,45 +1,49 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 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 { getFavoriteNodes, getRemoteURL, getRootPath } from '@nextcloud/files/dav' -import { CancelablePromise } from 'cancelable-promise' +import logger from '../logger.ts' import { getContents as filesContents } from './Files.ts' import { client } from './WebdavClient.ts' /** + * Get the contents for the favorites view * - * @param path + * @param path - The path to get the contents for + * @param options - Additional options + * @param options.signal - Optional AbortSignal to cancel the request + * @return A promise resolving to the contents with root folder */ -export function getContents(path = '/'): CancelablePromise { +export async function getContents(path = '/', options: { signal: AbortSignal }): Promise { // We only filter root files for favorites, for subfolders we can simply reuse the files contents - if (path !== '/') { - return filesContents(path) + if (path && path !== '/') { + return filesContents(path, options) } - return new CancelablePromise((resolve, reject, cancel) => { - const promise = getFavoriteNodes(client) - .catch(reject) - .then((contents) => { - if (!contents) { - reject() - return - } - resolve({ - contents, - folder: new Folder({ - id: 0, - source: `${getRemoteURL()}${getRootPath()}`, - root: getRootPath(), - owner: getCurrentUser()?.uid || null, - permissions: Permission.READ, - }), - }) - }) - cancel(() => promise.cancel()) - }) + try { + const contents = await getFavoriteNodes({ client, signal: options.signal }) + return { + contents, + folder: new Folder({ + id: 0, + source: `${getRemoteURL()}${getRootPath()}`, + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.READ, + }), + } + } catch (error) { + if (options.signal.aborted) { + logger.debug('Favorite nodes request was aborted') + throw new DOMException('Aborted', 'AbortError') + } + logger.error('Failed to load favorite nodes via WebDAV', { error }) + throw error + } } diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index d740661307d..0aafe9fe32f 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -6,7 +6,6 @@ import type { ContentsWithRoot, File, Folder } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed } from 'webdav' import { getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' -import { CancelablePromise } from 'cancelable-promise' import { join } from 'path' import logger from '../logger.ts' import { useFilesStore } from '../store/files.ts' @@ -20,66 +19,55 @@ import { searchNodes } from './WebDavSearch.ts' * This also allows to fetch local search results when the user is currently filtering. * * @param path - The path to query + * @param options - Options + * @param options.signal - Abort signal to cancel the request */ -export function getContents(path = '/'): CancelablePromise { - const controller = new AbortController() +export async function getContents(path = '/', options?: { signal: AbortSignal }): Promise { const searchStore = useSearchStore(getPinia()) - if (searchStore.query.length >= 3) { - return new CancelablePromise((resolve, reject, cancel) => { - cancel(() => controller.abort()) - getLocalSearch(path, searchStore.query, controller.signal) - .then(resolve) - .catch(reject) - }) - } else { - return defaultGetContents(path) + if (searchStore.query.length < 3) { + return await defaultGetContents(path, options) } + + return await getLocalSearch(path, searchStore.query, options?.signal) } /** * Generic `getContents` implementation for the users files. * * @param path - The path to get the contents + * @param options - Options + * @param options.signal - Abort signal to cancel the request */ -export function defaultGetContents(path: string): CancelablePromise { +export async function defaultGetContents(path: string, options?: { signal: AbortSignal }): Promise { path = join(getRootPath(), path) - const controller = new AbortController() const propfindPayload = getDefaultPropfind() - return new CancelablePromise(async (resolve, reject, onCancel) => { - onCancel(() => controller.abort()) + const contentsResponse = await client.getDirectoryContents(path, { + details: true, + data: propfindPayload, + includeSelf: true, + signal: options?.signal, + }) as ResponseDataDetailed - try { - const contentsResponse = await client.getDirectoryContents(path, { - details: true, - data: propfindPayload, - includeSelf: true, - signal: controller.signal, - }) as ResponseDataDetailed + const root = contentsResponse.data[0]! + const contents = contentsResponse.data.slice(1) + if (root?.filename !== path && `${root?.filename}/` !== path) { + logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`) + throw new Error('Root node does not match requested path') + } - const root = contentsResponse.data[0] - const contents = contentsResponse.data.slice(1) - if (root?.filename !== path && `${root?.filename}/` !== path) { - logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`) - throw new Error('Root node does not match requested path') + return { + folder: resultToNode(root) as Folder, + contents: contents.map((result) => { + try { + return resultToNode(result) + } catch (error) { + logger.error(`Invalid node detected '${result.basename}'`, { error }) + return null } - - resolve({ - folder: resultToNode(root) as Folder, - contents: contents.map((result) => { - try { - return resultToNode(result) - } catch (error) { - logger.error(`Invalid node detected '${result.basename}'`, { error }) - return null - } - }).filter(Boolean) as File[], - }) - } catch (error) { - reject(error) - } - }) + }).filter(Boolean) as File[], + } } /** @@ -89,7 +77,7 @@ export function defaultGetContents(path: string): CancelablePromise { +async function getLocalSearch(path: string, query: string, signal?: AbortSignal): Promise { const filesStore = useFilesStore(getPinia()) let folder = filesStore.getDirectoryByPath('files', path) if (!folder) { diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts index 888a4e6144e..8eff5e2ca36 100644 --- a/apps/files/src/services/FolderTree.ts +++ b/apps/files/src/services/FolderTree.ts @@ -4,7 +4,6 @@ */ import type { ContentsWithRoot } from '@nextcloud/files' -import type { CancelablePromise } from 'cancelable-promise' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' @@ -47,10 +46,11 @@ const collator = Intl.Collator( const compareNodes = (a: TreeNodeData, b: TreeNodeData) => collator.compare(a.displayName ?? a.basename, b.displayName ?? b.basename) /** + * Get all tree nodes recursively * - * @param tree - * @param currentPath - * @param nodes + * @param tree - The tree to process + * @param currentPath - The current path + * @param nodes - The nodes collected so far */ function getTreeNodes(tree: Tree, currentPath: string = '/', nodes: TreeNode[] = []): TreeNode[] { const sortedTree = tree.toSorted(compareNodes) @@ -76,9 +76,10 @@ function getTreeNodes(tree: Tree, currentPath: string = '/', nodes: TreeNode[] = } /** + * Get folder tree nodes * - * @param path - * @param depth + * @param path - The path to get the tree from + * @param depth - The depth to fetch */ export async function getFolderTreeNodes(path: string = '/', depth: number = 1): Promise { const { data: tree } = await axios.get(generateOcsUrl('/apps/files/api/v1/folder-tree'), { @@ -88,11 +89,12 @@ export async function getFolderTreeNodes(path: string = '/', depth: number = 1): return nodes } -export const getContents = (path: string): CancelablePromise => getFiles(path) +export const getContents = (path: string, options: { signal: AbortSignal }): Promise => getFiles(path, options) /** + * Encode source URL * - * @param source + * @param source - The source URL */ export function encodeSource(source: string): string { const { origin } = new URL(source) @@ -100,8 +102,9 @@ export function encodeSource(source: string): string { } /** + * Get parent source URL * - * @param source + * @param source - The source URL */ export function getSourceParent(source: string): string { const parent = dirname(source) diff --git a/apps/files/src/services/PersonalFiles.ts b/apps/files/src/services/PersonalFiles.ts index 8246bf5ac17..730e275fdaf 100644 --- a/apps/files/src/services/PersonalFiles.ts +++ b/apps/files/src/services/PersonalFiles.ts @@ -4,7 +4,6 @@ */ import type { ContentsWithRoot, Node } from '@nextcloud/files' -import type { CancelablePromise } from 'cancelable-promise' import { getCurrentUser } from '@nextcloud/auth' import { getContents as getFiles } from './Files.ts' @@ -31,13 +30,17 @@ export function isPersonalFile(node: Node): boolean { } /** + * Get personal files from a given path * - * @param path + * @param path - The path to get the personal files from + * @param options - Options + * @param options.signal - Abort signal to cancel the request + * @return A promise that resolves to the personal files */ -export function getContents(path: string = '/'): CancelablePromise { +export function getContents(path: string = '/', options: { signal: AbortSignal }): Promise { // get all the files from the current path as a cancellable promise // then filter the files that the user does not own, or has shared / is a group folder - return getFiles(path) + return getFiles(path, options) .then((content) => { content.contents = content.contents.filter(isPersonalFile) return content diff --git a/apps/files/src/services/Recent.ts b/apps/files/src/services/Recent.ts index a643653bffa..314426101b6 100644 --- a/apps/files/src/services/Recent.ts +++ b/apps/files/src/services/Recent.ts @@ -8,7 +8,7 @@ import type { ResponseDataDetailed, SearchResult } from 'webdav' import { getCurrentUser } from '@nextcloud/auth' import { Folder, Permission } from '@nextcloud/files' import { getRecentSearch, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav' -import { CancelablePromise } from 'cancelable-promise' +import logger from '../logger.ts' import { getPinia } from '../store/index.ts' import { useUserConfigStore } from '../store/userconfig.ts' import { client } from './WebdavClient.ts' @@ -22,8 +22,10 @@ const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 1 * If hidden files are not shown, then also recently changed files *in* hidden directories are filtered. * * @param path Path to search for recent changes + * @param options Options including abort signal + * @param options.signal Abort signal to cancel the request */ -export function getContents(path = '/'): CancelablePromise { +export async function getContents(path = '/', options: { signal: AbortSignal }): Promise { const store = useUserConfigStore(getPinia()) /** @@ -35,10 +37,9 @@ export function getContents(path = '/'): CancelablePromise { || store.userConfig.show_hidden // If configured to show hidden files we can early return || !node.dirname.split('/').some((dir) => dir.startsWith('.')) // otherwise only include the file if non of the parent directories is hidden - const controller = new AbortController() - const handler = async () => { + try { const contentsResponse = await client.search('/', { - signal: controller.signal, + signal: options.signal, details: true, data: getRecentSearch(lastTwoWeeksTimestamp), }) as ResponseDataDetailed @@ -61,10 +62,12 @@ export function getContents(path = '/'): CancelablePromise { }), contents, } + } catch (error) { + if (options.signal.aborted) { + logger.info('Fetching recent files aborted') + throw new DOMException('Aborted', 'AbortError') + } + logger.error('Failed to fetch recent files', { error }) + throw error } - - return new CancelablePromise(async (resolve, reject, cancel) => { - cancel(() => controller.abort()) - resolve(handler()) - }) } diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts index 1d98ae9f08f..2f8b23751ba 100644 --- a/apps/files/src/services/Search.spec.ts +++ b/apps/files/src/services/Search.spec.ts @@ -35,12 +35,12 @@ describe('Search service', () => { searchNodes.mockImplementationOnce(() => { throw new Error('expected error') }) - expect(getContents).rejects.toThrow('expected error') + expect(() => getContents('', { signal: new AbortController().signal })).rejects.toThrow('expected error') }) it('returns the search results and a fake root', async () => { searchNodes.mockImplementationOnce(() => [fakeFolder]) - const { contents, folder } = await getContents() + const { contents, folder } = await getContents('', { signal: new AbortController().signal }) expect(searchNodes).toHaveBeenCalledOnce() expect(contents).toHaveLength(1) @@ -57,8 +57,9 @@ describe('Search service', () => { return [] }) - const content = getContents() - content.cancel() + const controller = new AbortController() + getContents('', { signal: controller.signal }) + controller.abort() // its cancelled thus the promise returns the event const event = await promise diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts index 8949f02be6f..d210654aa71 100644 --- a/apps/files/src/services/Search.ts +++ b/apps/files/src/services/Search.ts @@ -8,7 +8,6 @@ import type { ContentsWithRoot } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' import { Folder, Permission } from '@nextcloud/files' import { defaultRemoteURL, getRootPath } from '@nextcloud/files/dav' -import { CancelablePromise } from 'cancelable-promise' import logger from '../logger.ts' import { getPinia } from '../store/index.ts' import { useSearchStore } from '../store/search.ts' @@ -16,29 +15,32 @@ import { searchNodes } from './WebDavSearch.ts' /** * Get the contents for a search view + * + * @param path - (not used) + * @param options - Options including abort signal + * @param options.signal - Abort signal to cancel the request */ -export function getContents(): CancelablePromise { - const controller = new AbortController() - +export async function getContents(path, options: { signal: AbortSignal }): Promise { const searchStore = useSearchStore(getPinia()) - return new CancelablePromise(async (resolve, reject, cancel) => { - cancel(() => controller.abort()) - try { - const contents = await searchNodes(searchStore.query, { signal: controller.signal }) - resolve({ - contents, - folder: new Folder({ - id: 0, - source: `${defaultRemoteURL}${getRootPath()}}#search`, - owner: getCurrentUser()!.uid, - permissions: Permission.READ, - root: getRootPath(), - }), - }) - } catch (error) { - logger.error('Failed to fetch search results', { error }) - reject(error) + try { + const contents = await searchNodes(searchStore.query, { signal: options.signal }) + return { + contents, + folder: new Folder({ + id: 0, + source: `${defaultRemoteURL}${getRootPath()}}#search`, + owner: getCurrentUser()!.uid, + permissions: Permission.READ, + root: getRootPath(), + }), } - }) + } catch (error) { + if (options.signal.aborted) { + logger.info('Fetching search results aborted') + throw new DOMException('Aborted', 'AbortError') + } + logger.error('Failed to fetch search results', { error }) + throw error + } } diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index eff1f533f2c..bba3d95cdea 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -161,7 +161,6 @@