mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
chore(files): adjust getContents to use AbortController
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
77f9897060
commit
9919c2bc91
11 changed files with 199 additions and 194 deletions
|
|
@ -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<ContentsWithRoot> {
|
||||
export async function getContents(path = '/', options: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContentsWithRoot> {
|
||||
const controller = new AbortController()
|
||||
export async function getContents(path = '/', options?: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
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<ContentsWithRoot> {
|
||||
export async function defaultGetContents(path: string, options?: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
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<FileStat[]>
|
||||
|
||||
try {
|
||||
const contentsResponse = await client.getDirectoryContents(path, {
|
||||
details: true,
|
||||
data: propfindPayload,
|
||||
includeSelf: true,
|
||||
signal: controller.signal,
|
||||
}) as ResponseDataDetailed<FileStat[]>
|
||||
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<ContentsWith
|
|||
* @param query - The current search query
|
||||
* @param signal - The aboort signal
|
||||
*/
|
||||
async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> {
|
||||
async function getLocalSearch(path: string, query: string, signal?: AbortSignal): Promise<ContentsWithRoot> {
|
||||
const filesStore = useFilesStore(getPinia())
|
||||
let folder = filesStore.getDirectoryByPath('files', path)
|
||||
if (!folder) {
|
||||
|
|
|
|||
|
|
@ -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<TreeNode[]> {
|
||||
const { data: tree } = await axios.get<Tree>(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<ContentsWithRoot> => getFiles(path)
|
||||
export const getContents = (path: string, options: { signal: AbortSignal }): Promise<ContentsWithRoot> => 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)
|
||||
|
|
|
|||
|
|
@ -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<ContentsWithRoot> {
|
||||
export function getContents(path: string = '/', options: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<ContentsWithRoot> {
|
||||
export async function getContents(path = '/', options: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
const store = useUserConfigStore(getPinia())
|
||||
|
||||
/**
|
||||
|
|
@ -35,10 +37,9 @@ export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
|
|||
|| 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<SearchResult>
|
||||
|
|
@ -61,10 +62,12 @@ export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
|
|||
}),
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ContentsWithRoot> {
|
||||
const controller = new AbortController()
|
||||
|
||||
export async function getContents(path, options: { signal: AbortSignal }): Promise<ContentsWithRoot> {
|
||||
const searchStore = useSearchStore(getPinia())
|
||||
|
||||
return new CancelablePromise<ContentsWithRoot>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@
|
|||
<script lang="ts">
|
||||
import type { ContentsWithRoot, FileListAction, INode, Node } from '@nextcloud/files'
|
||||
import type { Upload } from '@nextcloud/upload'
|
||||
import type { CancelablePromise } from 'cancelable-promise'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type { Route } from 'vue-router'
|
||||
import type { UserConfig } from '../types.ts'
|
||||
|
|
@ -295,7 +294,8 @@ export default defineComponent({
|
|||
loading: true,
|
||||
loadingAction: null as string | null,
|
||||
error: null as string | null,
|
||||
promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
|
||||
controller: new AbortController(),
|
||||
promise: null as Promise<ContentsWithRoot> | null,
|
||||
|
||||
dirContentsFiltered: [] as INode[],
|
||||
}
|
||||
|
|
@ -640,13 +640,14 @@ export default defineComponent({
|
|||
logger.debug('Fetching contents for directory', { dir, currentView })
|
||||
|
||||
// If we have a cancellable promise ongoing, cancel it
|
||||
if (this.promise && 'cancel' in this.promise) {
|
||||
this.promise.cancel()
|
||||
if (this.promise) {
|
||||
this.controller.abort()
|
||||
logger.debug('Cancelled previous ongoing fetch')
|
||||
}
|
||||
|
||||
// Fetch the current dir contents
|
||||
this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
|
||||
this.controller = new AbortController()
|
||||
this.promise = currentView.getContents(dir, { signal: this.controller.signal })
|
||||
try {
|
||||
const { folder, contents } = await this.promise
|
||||
logger.debug('Fetched contents', { dir, folder, contents })
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Folder as CFolder, Navigation } from '@nextcloud/files'
|
||||
import type { Navigation, Folder as NcFolder } from '@nextcloud/files'
|
||||
|
||||
import * as eventBus from '@nextcloud/event-bus'
|
||||
import * as filesUtils from '@nextcloud/files'
|
||||
import * as filesDavUtils from '@nextcloud/files/dav'
|
||||
import { CancelablePromise } from 'cancelable-promise'
|
||||
import { basename } from 'path'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { action } from '../actions/favoriteAction.ts'
|
||||
|
|
@ -42,8 +41,8 @@ describe('Favorites view definition', () => {
|
|||
|
||||
test('Default empty favorite view', async () => {
|
||||
vi.spyOn(eventBus, 'subscribe')
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(Promise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as NcFolder, contents: [] }))
|
||||
|
||||
await registerFavoritesView()
|
||||
const favoritesView = Navigation.views.find((view) => view.id === 'favorites')
|
||||
|
|
@ -95,8 +94,8 @@ describe('Favorites view definition', () => {
|
|||
owner: 'admin',
|
||||
}),
|
||||
]
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(Promise.resolve(favoriteFolders))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as NcFolder, contents: favoriteFolders }))
|
||||
|
||||
await registerFavoritesView()
|
||||
const favoritesView = Navigation.views.find((view) => view.id === 'favorites')
|
||||
|
|
@ -140,8 +139,8 @@ describe('Dynamic update of favorite folders', () => {
|
|||
|
||||
test('Add a favorite folder creates a new entry in the navigation', async () => {
|
||||
vi.spyOn(eventBus, 'emit')
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(Promise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as NcFolder, contents: [] }))
|
||||
|
||||
await registerFavoritesView()
|
||||
const favoritesView = Navigation.views.find((view) => view.id === 'favorites')
|
||||
|
|
@ -164,7 +163,7 @@ describe('Dynamic update of favorite folders', () => {
|
|||
await action.exec({
|
||||
nodes: [folder],
|
||||
view: favoritesView,
|
||||
folder: {} as CFolder,
|
||||
folder: {} as NcFolder,
|
||||
contents: [],
|
||||
})
|
||||
|
||||
|
|
@ -173,16 +172,15 @@ describe('Dynamic update of favorite folders', () => {
|
|||
})
|
||||
|
||||
test('Remove a favorite folder remove the entry from the navigation column', async () => {
|
||||
const favoriteFolders = [new Folder({
|
||||
id: 42,
|
||||
root: '/files/admin',
|
||||
source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
|
||||
owner: 'admin',
|
||||
})]
|
||||
vi.spyOn(eventBus, 'emit')
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([
|
||||
new Folder({
|
||||
id: 42,
|
||||
root: '/files/admin',
|
||||
source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
|
||||
owner: 'admin',
|
||||
}),
|
||||
]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(Promise.resolve(favoriteFolders))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as NcFolder, contents: favoriteFolders }))
|
||||
|
||||
await registerFavoritesView()
|
||||
let favoritesView = Navigation.views.find((view) => view.id === 'favorites')
|
||||
|
|
@ -211,7 +209,7 @@ describe('Dynamic update of favorite folders', () => {
|
|||
await action.exec({
|
||||
nodes: [folder],
|
||||
view: favoritesView,
|
||||
folder: {} as CFolder,
|
||||
folder: {} as NcFolder,
|
||||
contents: [],
|
||||
})
|
||||
|
||||
|
|
@ -230,8 +228,8 @@ describe('Dynamic update of favorite folders', () => {
|
|||
|
||||
test('Renaming a favorite folder updates the navigation', async () => {
|
||||
vi.spyOn(eventBus, 'emit')
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
|
||||
vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(Promise.resolve([]))
|
||||
vi.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as NcFolder, contents: [] }))
|
||||
|
||||
await registerFavoritesView()
|
||||
const favoritesView = Navigation.views.find((view) => view.id === 'favorites')
|
||||
|
|
@ -256,7 +254,7 @@ describe('Dynamic update of favorite folders', () => {
|
|||
await action.exec({
|
||||
nodes: [folder],
|
||||
view: favoritesView,
|
||||
folder: {} as CFolder,
|
||||
folder: {} as NcFolder,
|
||||
contents: [],
|
||||
})
|
||||
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
|
@ -9,17 +9,16 @@ import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw'
|
|||
import StarSvg from '@mdi/svg/svg/star-outline.svg?raw'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { FileType, getNavigation, View } from '@nextcloud/files'
|
||||
import { getFavoriteNodes } from '@nextcloud/files/dav'
|
||||
import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
|
||||
import logger from '../logger.ts'
|
||||
import { getContents } from '../services/Favorites.ts'
|
||||
import { client } from '../services/WebdavClient.ts'
|
||||
import { hashCode } from '../utils/hashUtils.ts'
|
||||
|
||||
/**
|
||||
* Generate a favorite folder view
|
||||
*
|
||||
* @param folder
|
||||
* @param index
|
||||
* @param folder - The folder to generate the view for
|
||||
* @param index - The order index
|
||||
*/
|
||||
function generateFavoriteFolderView(folder: Folder, index = 0): View {
|
||||
return new View({
|
||||
|
|
@ -44,15 +43,16 @@ function generateFavoriteFolderView(folder: Folder, index = 0): View {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate a unique id from the folder path
|
||||
*
|
||||
* @param path
|
||||
* @param path - The folder path
|
||||
*/
|
||||
function generateIdFromPath(path: string): string {
|
||||
return `favorite-${hashCode(path)}`
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Register the favorites view and setup event listeners to update it
|
||||
*/
|
||||
export async function registerFavoritesView() {
|
||||
const Navigation = getNavigation()
|
||||
|
|
@ -72,8 +72,11 @@ export async function registerFavoritesView() {
|
|||
getContents,
|
||||
}))
|
||||
|
||||
const favoriteFolders = (await getFavoriteNodes(client)).filter((node) => node.type === FileType.Folder) as Folder[]
|
||||
const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
|
||||
const controller = new AbortController()
|
||||
const favoriteFolders = (await getContents('', { signal: controller.signal })).contents
|
||||
.filter((node) => node.type === FileType.Folder) as Folder[]
|
||||
const favoriteFoldersViews = favoriteFolders
|
||||
.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
|
||||
logger.debug('Generating favorites view', { favoriteFolders })
|
||||
favoriteFoldersViews.forEach((view) => Navigation.register(view))
|
||||
|
||||
|
|
@ -143,7 +146,7 @@ export async function registerFavoritesView() {
|
|||
/**
|
||||
* Add a folder to the favorites paths array and update the views
|
||||
*
|
||||
* @param node
|
||||
* @param node - The folder node
|
||||
*/
|
||||
function addToFavorites(node: Folder) {
|
||||
const view = generateFavoriteFolderView(node)
|
||||
|
|
@ -165,7 +168,7 @@ export async function registerFavoritesView() {
|
|||
/**
|
||||
* Remove a folder from the favorites paths array and update the views
|
||||
*
|
||||
* @param path
|
||||
* @param path - The folder path
|
||||
*/
|
||||
function removePathFromFavorites(path: string) {
|
||||
const id = generateIdFromPath(path)
|
||||
|
|
@ -188,7 +191,7 @@ export async function registerFavoritesView() {
|
|||
/**
|
||||
* Update a folder from the favorites paths array and update the views
|
||||
*
|
||||
* @param node
|
||||
* @param node - The updated folder node
|
||||
*/
|
||||
function updateNodeFromFavorites(node: Folder) {
|
||||
const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import LinkSvg from '@mdi/svg/svg/link.svg?raw'
|
|||
import { Folder, getNavigation, Permission, View } from '@nextcloud/files'
|
||||
import { getDefaultPropfind, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { CancelablePromise } from 'cancelable-promise'
|
||||
import { client } from '../../../files/src/services/WebdavClient.ts'
|
||||
import logger from '../services/logger.ts'
|
||||
|
||||
|
|
@ -25,41 +24,41 @@ export default () => {
|
|||
icon: LinkSvg,
|
||||
order: 1,
|
||||
|
||||
getContents: () => {
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
const abort = new AbortController()
|
||||
onCancel(() => abort.abort())
|
||||
try {
|
||||
const node = await client.stat(
|
||||
getRootPath(),
|
||||
{
|
||||
data: getDefaultPropfind(),
|
||||
details: true,
|
||||
signal: abort.signal,
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
getContents: async (path, { signal }) => {
|
||||
try {
|
||||
const node = await client.stat(
|
||||
getRootPath(),
|
||||
{
|
||||
data: getDefaultPropfind(),
|
||||
details: true,
|
||||
signal,
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
|
||||
resolve({
|
||||
// We only have one file as the content
|
||||
contents: [resultToNode(node.data)],
|
||||
// Fake a readonly folder as root
|
||||
folder: new Folder({
|
||||
id: 0,
|
||||
source: `${getRemoteURL()}${getRootPath()}`,
|
||||
root: getRootPath(),
|
||||
owner: null,
|
||||
permissions: Permission.READ,
|
||||
attributes: {
|
||||
// Ensure the share note is set on the root
|
||||
note: node.data.props?.note,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(e as Error)
|
||||
reject(e as Error)
|
||||
return {
|
||||
// We only have one file as the content
|
||||
contents: [resultToNode(node.data)],
|
||||
// Fake a readonly folder as root
|
||||
folder: new Folder({
|
||||
id: 0,
|
||||
source: `${getRemoteURL()}${getRootPath()}`,
|
||||
root: getRootPath(),
|
||||
owner: null,
|
||||
permissions: Permission.READ,
|
||||
attributes: {
|
||||
// Ensure the share note is set on the root
|
||||
note: node.data.props?.note,
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
logger.info('Fetching contents for public file share was aborted', { error })
|
||||
throw new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
logger.error('Failed to get contents for public file share', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue