diff --git a/apps/files/src/composables/useLiveFolderRefresh.spec.ts b/apps/files/src/composables/useLiveFolderRefresh.spec.ts new file mode 100644 index 0000000000000..455bc3861395d --- /dev/null +++ b/apps/files/src/composables/useLiveFolderRefresh.spec.ts @@ -0,0 +1,358 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode, IView } from '@nextcloud/files' + +import { showInfo } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { File, Folder } from '@nextcloud/files' +import { listen } from '@nextcloud/notify_push' +import { cleanup, render } from '@testing-library/vue' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { getChildrenEtags } from '../services/Files.ts' +import { getNodesById } from '../services/NodesApi.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { MAX_CHANGED_NODES, useLiveFolderRefresh } from './useLiveFolderRefresh.ts' + +const handlers = vi.hoisted(() => ({} as Record void>)) + +vi.mock('@nextcloud/notify_push', () => ({ + listen: vi.fn((name: string, handler: (name: string, body: unknown) => void) => { + handlers[name] = handler + return true + }), +})) +vi.mock('@nextcloud/auth', async (importOriginal) => ({ + ...await importOriginal(), + getCurrentUser: () => ({ uid: 'alice' }), +})) +vi.mock('@nextcloud/dialogs', () => ({ showInfo: vi.fn() })) +vi.mock('@nextcloud/sharing/public', () => ({ isPublicShare: () => false })) +vi.mock('@nextcloud/event-bus', async (importOriginal) => ({ + ...await importOriginal(), + emit: vi.fn(), +})) +vi.mock('../services/NodesApi.ts', () => ({ getNodesById: vi.fn() })) +vi.mock('../services/Files.ts', () => ({ getChildrenEtags: vi.fn() })) + +const base = 'https://cloud.example.com/remote.php/dav/files/alice' +const goToRoute = vi.fn() +window.OCP = { Files: { Router: { params: { view: 'files' }, query: { dir: '/folder' }, goToRoute } } } as unknown as typeof window.OCP + +/** + * @param id - The file id + * @param path - The path within the user files + * @param attributes - The attributes + */ +function file(id: number, path: string, attributes: Record = {}) { + return new File({ id, owner: 'alice', source: `${base}${path}`, root: '/files/alice', mime: 'text/plain', attributes: { etag: `etag-${id}`, ...attributes } }) +} + +/** + * @param id - The file id + * @param path - The path within the user files + */ +function folderAt(id: number, path: string) { + return new Folder({ id, owner: 'alice', source: `${base}${path}`.replace(/\/$/, ''), root: '/files/alice' }) +} + +const root = folderAt(1, '/') +const folder = folderAt(10, '/folder') +const report = file(11, '/folder/report.md') +const notes = file(12, '/folder/notes.md') + +/** + * Serve these nodes from the nodes API, filtered like the server does + * + * @param nodes - The nodes that exist + */ +function serve(nodes: INode[]) { + vi.mocked(getNodesById).mockImplementation(async (fileIds, options = {}) => nodes.filter((node) => fileIds.includes(node.fileid!) + && (!options.dir || node.path === options.dir || node.path.startsWith(`${options.dir}/`)))) +} + +/** + * Mount a component using the composable, with the given folder open + * + * @param current - The open folder + * @param children - Its children + * @param view - The current view id + */ +function mountList(current: Folder, children: INode[], view = 'files') { + const filesStore = useFilesStore() + filesStore.updateNodes([root, current, ...children]) + filesStore.setRoot({ service: 'files', root }) + usePathsStore().addPath({ service: 'files', path: current.path, source: current.source }) + ;(current as Folder & { _children?: string[] })._children = children.map((node) => node.source) + const activeStore = useActiveStore() + activeStore.activeFolder = current + activeStore.activeView = { id: view } as IView + + render(defineComponent({ + template: '
', + setup: () => useLiveFolderRefresh(), + })) +} + +/** + * @param fileIds - The pushed file ids + */ +function push(fileIds: number[]) { + handlers.notify_file_id!('notify_file_id', fileIds) +} + +/** + * Let the queued refresh run + */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('composable: useLiveFolderRefresh', () => { + beforeEach(() => { + vi.mocked(emit).mockClear() + vi.mocked(showInfo).mockClear() + goToRoute.mockClear() + vi.mocked(getNodesById).mockReset() + vi.mocked(getChildrenEtags).mockReset() + setActivePinia(createPinia()) + serve([root, folder, report, notes]) + }) + + afterEach(cleanup) + + it('listens to file changes with their ids once', () => { + mountList(folder, [report]) + mountList(folder, [report]) + + expect(listen).toHaveBeenCalledTimes(2) + expect(listen).toHaveBeenCalledWith('notify_file_id', expect.any(Function)) + expect(listen).toHaveBeenCalledWith('notify_file', expect.any(Function)) + }) + + it('ignores changes outside of the current folder', async () => { + mountList(folder, [report]) + + // A change in a sibling folder of the root: the root is pushed, an unknown id too + push([99, 1]) + await settle() + + // Only checks that the current folder is still there + expect(getNodesById).toHaveBeenCalledOnce() + expect(getNodesById).toHaveBeenCalledWith([10, 1]) + expect(emit).not.toHaveBeenCalled() + }) + + it('only fetches the changed children and the new nodes, with the folder', async () => { + mountList(folder, [report, notes]) + const updated = file(11, '/folder/report.md', { etag: 'new-etag' }) + const created = file(20, '/folder/new.md') + serve([root, folder, updated, created, notes]) + + push([11, 20, 10]) + await settle() + + expect(getNodesById).toHaveBeenCalledOnce() + expect(getNodesById).toHaveBeenCalledWith([10, 11, 20], { dir: '/folder' }) + expect(emit).toHaveBeenCalledWith('files:node:updated', updated) + expect(emit).toHaveBeenCalledWith('files:node:created', created) + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('updates the current folder without event', async () => { + mountList(folder, [report]) + const changed = folderAt(10, '/folder') + changed.attributes.etag = 'new-etag' + serve([root, changed, report]) + + push([11, 10]) + await settle() + + expect(useFilesStore().getNode(folder.source)?.attributes.etag).toBe('new-etag') + expect(emit).not.toHaveBeenCalledWith('files:node:updated', changed) + }) + + it('does not fetch unknown ids when the current folder did not change', async () => { + mountList(folder, [report]) + + // A new file in a subfolder: its parent is pushed, not the current folder + push([30, 31]) + await settle() + + expect(getNodesById).not.toHaveBeenCalled() + }) + + it('keeps the attributes the nodes API does not provide yet', async () => { + const known = file(11, '/folder/report.md', { 'comments-unread': 2 }) + mountList(folder, [known]) + const updated = file(11, '/folder/report.md', { etag: 'new-etag' }) + serve([root, folder, updated]) + + push([11, 10]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:updated', updated) + expect(updated.attributes).toMatchObject({ etag: 'new-etag', 'comments-unread': 2 }) + }) + + it('matches a file listed twice by path', async () => { + const first = file(11, '/folder/report.md') + const second = file(11, '/folder/report (2).md') + mountList(folder, [first, second]) + // The second share of the file was removed + serve([root, folder, first]) + + push([11, 10]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:updated', first) + expect(emit).toHaveBeenCalledWith('files:node:deleted', second) + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('moves renamed children', async () => { + mountList(folder, [report]) + const renamed = file(11, '/folder/final report.md') + serve([root, folder, renamed]) + + push([11, 10]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:moved', { node: renamed, oldSource: report.source }) + expect(emit).not.toHaveBeenCalledWith('files:node:deleted', report) + }) + + it('ignores new nodes deeper in the folder', async () => { + mountList(folder, [report]) + serve([root, folder, report, file(30, '/folder/sub/deep.md')]) + + push([30, 10]) + await settle() + + expect(emit).not.toHaveBeenCalled() + }) + + it('deletes children that are not in the folder anymore', async () => { + mountList(folder, [report, notes]) + serve([root, folder, report, file(12, '/elsewhere/notes.md')]) + + push([12, 10, 1]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:deleted', notes) + expect(emit).toHaveBeenCalledTimes(1) + }) + + it('reconciles the folder when only the folder itself changed', async () => { + mountList(folder, [report, notes]) + // notes.md went to the trash bin, older notify_push versions only push its parent + vi.mocked(getChildrenEtags).mockResolvedValue(new Map([[11, 'etag-11']])) + + push([10]) + await settle() + + expect(getChildrenEtags).toHaveBeenCalledWith('/folder') + expect(emit).toHaveBeenCalledWith('files:node:deleted', notes) + expect(getNodesById).toHaveBeenCalledWith([10], { dir: '/folder' }) + }) + + it('reconciles the folder when too many children changed', async () => { + const children = Array.from({ length: MAX_CHANGED_NODES + 1 }, (_, index) => file(100 + index, `/folder/${index}.md`)) + mountList(folder, children) + vi.mocked(getChildrenEtags).mockResolvedValue(new Map(children.map((child) => [child.fileid!, child.attributes.etag as string]))) + + push([...children.map((child) => child.fileid!), 10]) + await settle() + + expect(getChildrenEtags).toHaveBeenCalledWith('/folder') + expect(getNodesById).toHaveBeenCalledWith([10], { dir: '/folder' }) + }) + + it('follows the current folder when it was moved', async () => { + mountList(folder, [report]) + const moved = folderAt(10, '/archive/folder') + serve([root, moved]) + + push([1]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:moved', { node: moved, oldSource: folder.source }) + expect(goToRoute).toHaveBeenCalledWith(null, { view: 'files', fileid: '10' }, { dir: '/archive/folder' }) + }) + + it('goes to the closest ancestor when the current folder was deleted', async () => { + mountList(folder, [report]) + serve([root]) + + push([1]) + await settle() + + expect(emit).toHaveBeenCalledWith('files:node:deleted', folder) + expect(showInfo).toHaveBeenCalledOnce() + expect(goToRoute).toHaveBeenCalledWith(null, { view: 'files', fileid: '1' }, { dir: '/' }) + }) + + it('waits for the page to be visible', async () => { + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + mountList(folder, [report]) + + push([11, 10]) + await settle() + expect(getNodesById).not.toHaveBeenCalled() + + visibility.mockReturnValue('visible') + document.dispatchEvent(new Event('visibilitychange')) + await settle() + expect(getNodesById).toHaveBeenCalledWith([10, 11], { dir: '/folder' }) + visibility.mockRestore() + }) + + it('only refreshes the files view', async () => { + mountList(folder, [report], 'recent') + + push([11, 10]) + await settle() + + expect(getNodesById).not.toHaveBeenCalled() + }) + + it('reconciles the root folder when shares changed', async () => { + mountList(root, [folder]) + vi.mocked(getChildrenEtags).mockResolvedValue(new Map([[10, folder.attributes.etag as string]])) + + handlers.notify_file!('notify_file', null) + await settle() + + expect(getChildrenEtags).toHaveBeenCalledWith('/') + }) + + it('ignores shares changes outside of the root folder', async () => { + mountList(folder, [report]) + + handlers.notify_file!('notify_file', null) + await settle() + + expect(getChildrenEtags).not.toHaveBeenCalled() + }) + + it('drops pending changes when no list is mounted', async () => { + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + mountList(folder, [report]) + push([11, 10]) + cleanup() + + visibility.mockReturnValue('visible') + mountList(folder, [report]) + document.dispatchEvent(new Event('visibilitychange')) + await settle() + + expect(getNodesById).not.toHaveBeenCalled() + visibility.mockRestore() + }) +}) diff --git a/apps/files/src/composables/useLiveFolderRefresh.ts b/apps/files/src/composables/useLiveFolderRefresh.ts new file mode 100644 index 0000000000000..756b3bbf9ca83 --- /dev/null +++ b/apps/files/src/composables/useLiveFolderRefresh.ts @@ -0,0 +1,280 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFolder, INode } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { showInfo } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import { listen } from '@nextcloud/notify_push' +import { dirname } from '@nextcloud/paths' +import { isPublicShare } from '@nextcloud/sharing/public' +import { onMounted, onUnmounted } from 'vue' +import { getChildrenEtags } from '../services/Files.ts' +import { getNodesById } from '../services/NodesApi.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files.ts' +import { logger } from '../utils/logger.ts' + +/** + * Views listing the plain folder contents of the user files + */ +const LIVE_VIEWS = ['files'] + +/** + * Above this many changed children, compare a light listing of the folder instead + */ +export const MAX_CHANGED_NODES = 200 + +const pendingFileIds = new Set() +let pendingReconcile = false +let flushing: Promise | null = null +let mountedLists = 0 +let registered = false + +/** + * Keep the current folder of the files list up to date with changes + * made elsewhere, from the file ids pushed by the notify_push app. + * + * Only the changed nodes are fetched, never the whole folder, + * except when the push does not say which child changed. + */ +export function useLiveFolderRefresh() { + onMounted(() => { + mountedLists++ + register() + }) + onUnmounted(() => { + mountedLists-- + if (mountedLists === 0) { + pendingFileIds.clear() + pendingReconcile = false + } + }) +} + +/** + * Start listening to notify_push, once per page. + * There is no way to stop listening, the handlers check whether a list is mounted. + */ +function register() { + if (registered || isPublicShare() || !getCurrentUser()) { + return + } + registered = true + + const available = listen('notify_file_id', (_, fileIds: number[]) => { + fileIds.forEach((fileId) => pendingFileIds.add(fileId)) + schedule() + }) + if (!available) { + logger.debug('notify_push is not available, the files list will not refresh by itself') + return + } + + // Pushed without file ids when shares or group memberships change, + // which adds or removes mount points in the root folder + listen('notify_file', () => { + pendingReconcile = true + schedule() + }) + + document.addEventListener('visibilitychange', () => schedule()) +} + +/** + * Apply the pending changes, one batch at a time and only while the page is visible + */ +function schedule() { + if (document.visibilityState === 'hidden' || flushing || mountedLists === 0) { + return + } + + if (pendingFileIds.size === 0 && !pendingReconcile) { + return + } + + flushing = flush() + .catch((error) => logger.error('Could not refresh the files list', { error })) + .finally(() => { + flushing = null + schedule() + }) +} + +/** + * Apply the pending changes to the current folder + */ +async function flush() { + const fileIds = new Set(pendingFileIds) + const reconcile = pendingReconcile + pendingFileIds.clear() + pendingReconcile = false + + const activeStore = useActiveStore() + const filesStore = useFilesStore() + const folder = activeStore.activeFolder as (IFolder & { _children?: string[] }) | undefined + if (!folder?.fileid || !LIVE_VIEWS.includes(activeStore.activeView?.id ?? '')) { + return + } + + // The parent of a changed node is always pushed along with it: when the parent + // of the current folder changed, the current folder itself may be gone + const parentId = folder.path === '/' ? undefined : filesStore.getDirectoryByPath('files', dirname(folder.path))?.fileid + if (parentId !== undefined && fileIds.has(parentId) && !await checkFolderStillExists(folder)) { + return + } + + // Children are matched by source: the same file can be listed twice, through two shares + const children = filesStore.getNodes(folder._children ?? []) + const folderChanged = fileIds.has(folder.fileid) + // Ancestors of a changed node are pushed along with it, only children of the current folder matter. + // A new file always comes with its parent, so unknown ids only count when the current folder changed. + const changedIds = [...fileIds].filter((fileId) => fileId !== folder.fileid + && (children.some((child) => child.fileid === fileId) + || (folderChanged && filesStore.getNodesById(String(fileId)).length === 0))) + + if (changedIds.length > 0 && changedIds.length <= MAX_CHANGED_NODES) { + await refreshNodes(folder, children, changedIds) + } else if (changedIds.length > 0 || folderChanged || (reconcile && folder.path === '/')) { + // Too many changes, or only the folder changed: a child was deleted and notify_push + // does not send the id of files moved to the trash bin before nextcloud/notify_push#776 + await reconcileFolder(folder, children) + } +} + +/** + * Fetch the current folder and the given nodes, and apply them to the files list + * + * @param folder - The current folder + * @param children - The current children + * @param fileIds - The ids of the changed nodes + */ +async function refreshNodes(folder: IFolder, children: INode[], fileIds: number[]) { + const nodes = await getNodesById([folder.fileid!, ...fileIds], { dir: folder.path }) + const folderNode = nodes.find((node) => node.source === folder.source) + if (!folderNode) { + await checkFolderStillExists(folder) + return + } + + logger.debug('Refreshing changed nodes in the current folder', { folder, fileIds, nodes }) + // Without event: the files list refetches the whole folder when the current folder is updated + useFilesStore().updateNodes([withKnownAttributes(folderNode, folder)]) + + const found = new Set() + for (const node of nodes) { + if (node === folderNode) { + continue + } + + found.add(node.source) + const known = children.find((child) => child.source === node.source) + const moved = children.find((child) => child.fileid === node.fileid && !nodes.some((other) => other.source === child.source)) + if (known) { + emit('files:node:updated', withKnownAttributes(node, known)) + } else if (moved) { + // Renamed, or moved into a subfolder + found.add(moved.source) + emit('files:node:moved', { node: withKnownAttributes(node, moved), oldSource: moved.source }) + } else if (dirname(node.source) === folder.source) { + emit('files:node:created', node) + } + } + + // Requested children that were not found are not within this folder anymore + children + .filter((child) => fileIds.includes(child.fileid!) && !found.has(child.source)) + .forEach((child) => emit('files:node:deleted', child)) +} + +/** + * The nodes API does not provide every attribute of a listing yet, + * keep the ones it does not provide from the node we already have + * + * @param node - The fetched node + * @param known - The node we already have + */ +function withKnownAttributes(node: INode, known: INode): INode { + for (const [key, value] of Object.entries(known.attributes)) { + if (!(key in node.attributes)) { + node.attributes[key] = value + } + } + return node +} + +/** + * Check that the current folder is still at its path. + * If it moved, follow it. If it was deleted, go to its closest remaining ancestor. + * + * @param folder - The current folder + * @return Whether the folder is still at its path + */ +async function checkFolderStillExists(folder: IFolder): Promise { + const filesStore = useFilesStore() + const parts = folder.path.split('/').filter(Boolean).slice(0, -1) + const ancestors = ['/', ...parts.map((_, index) => '/' + parts.slice(0, index + 1).join('/'))] + .map((path) => filesStore.getDirectoryByPath('files', path)) + .filter((node): node is IFolder => node?.fileid !== undefined) + const nodes = await getNodesById([folder.fileid!, ...ancestors.map((node) => node.fileid!)]) + + if (nodes.some((node) => node.source === folder.source)) { + return true + } + + const movedTo = nodes.find((node) => node.fileid === folder.fileid) + if (movedTo) { + logger.debug('The current folder was moved', { folder, movedTo }) + emit('files:node:moved', { node: movedTo, oldSource: folder.source }) + goToFolder(movedTo) + return false + } + + const remaining = ancestors.reverse() + .map((ancestor) => nodes.find((node) => node.source === ancestor.source)) + .find((node) => node !== undefined) + logger.debug('The current folder was deleted', { folder, remaining }) + emit('files:node:deleted', folder) + showInfo(t('files', '"{name}" was deleted', { name: folder.displayname })) + goToFolder(remaining) + return false +} + +/** + * Open a folder in the files list, the root folder if none + * + * @param folder - The folder to open + */ +function goToFolder(folder?: INode) { + const params = { ...window.OCP.Files.Router.params } + delete params.fileid + window.OCP.Files.Router.goToRoute( + null, + folder?.fileid ? { ...params, fileid: String(folder.fileid) } : params, + { ...window.OCP.Files.Router.query, dir: folder?.path ?? '/' }, + ) +} + +/** + * Compare the current children with a light listing of the folder, + * then fetch the new and changed nodes only + * + * @param folder - The current folder + * @param children - The current children + */ +async function reconcileFolder(folder: IFolder, children: INode[]) { + const etags = await getChildrenEtags(folder.path) + + const changedIds = [...etags] + .filter(([fileId, etag]) => !children.some((child) => child.fileid === fileId && child.attributes.etag === etag)) + .map(([fileId]) => fileId) + const deleted = children.filter((node) => !etags.has(node.fileid!)) + + logger.debug('Reconciled the current folder', { folder, changedIds, deleted }) + deleted.forEach((node) => emit('files:node:deleted', node)) + await refreshNodes(folder, children, changedIds) +} diff --git a/apps/files/src/services/Files.spec.ts b/apps/files/src/services/Files.spec.ts new file mode 100644 index 0000000000000..12d63fce0dbf4 --- /dev/null +++ b/apps/files/src/services/Files.spec.ts @@ -0,0 +1,43 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getChildrenEtags } from './Files.ts' + +const getDirectoryContents = vi.hoisted(() => vi.fn()) +vi.mock('./WebdavClient.ts', () => ({ client: { getDirectoryContents } })) +vi.mock('@nextcloud/auth', async (importOriginal) => ({ + ...await importOriginal(), + getCurrentUser: () => ({ uid: 'alice' }), +})) + +describe('getChildrenEtags', () => { + beforeEach(() => { + getDirectoryContents.mockReset() + }) + + it('only requests the file ids and etags of the children', async () => { + getDirectoryContents.mockResolvedValue({ data: [] }) + + await getChildrenEtags('/folder') + + const [path, options] = getDirectoryContents.mock.calls[0]! + expect(path).toBe('/files/alice/folder') + expect(options.data).toContain('') + expect(options.includeSelf).toBeUndefined() + }) + + it('maps the file ids to their etags', async () => { + getDirectoryContents.mockResolvedValue({ + data: [ + { filename: '/files/alice/folder/a.md', etag: 'etag-a', props: { fileid: 11 } }, + { filename: '/files/alice/folder/b.md', etag: 'etag-b', props: { fileid: '12' } }, + { filename: '/files/alice/folder/broken', etag: null, props: {} }, + ], + }) + + expect(await getChildrenEtags('/folder')).toEqual(new Map([[11, 'etag-a'], [12, 'etag-b']])) + }) +}) diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index e80a9d60b9aa0..0a80e1769ebe3 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -5,7 +5,7 @@ import type { ContentsWithRoot, File, Folder } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed } from 'webdav' -import { getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' +import { getDavNameSpaces, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' import { join } from 'path' import { useFilesStore } from '../store/files.ts' import { pinia } from '../store/index.ts' @@ -91,3 +91,23 @@ async function getLocalSearch(path: string, query: string, signal?: AbortSignal) contents, } } + +/** + * List the file ids and etags of the direct children of a folder, + * a much lighter request than a full listing for large folders. + * + * @param path - The folder path + * @param options - Options + * @param options.signal - Abort signal to cancel the request + */ +export async function getChildrenEtags(path: string, options?: { signal: AbortSignal }): Promise> { + const contentsResponse = await client.getDirectoryContents(join(getRootPath(), path), { + details: true, + data: ``, + signal: options?.signal, + }) as ResponseDataDetailed + + return new Map(contentsResponse.data + .filter((result) => result.props?.fileid !== undefined) + .map((result) => [Number(result.props!.fileid), String(result.etag ?? '')])) +} diff --git a/apps/files/src/services/NodesApi.spec.ts b/apps/files/src/services/NodesApi.spec.ts new file mode 100644 index 0000000000000..a2ea420088076 --- /dev/null +++ b/apps/files/src/services/NodesApi.spec.ts @@ -0,0 +1,77 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { NodeData } from './NodesApi.ts' + +import axios from '@nextcloud/axios' +import { File, Folder } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dataToNode, getNodesById, MAX_NODE_IDS } from './NodesApi.ts' + +vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn() } })) +vi.mock('@nextcloud/auth', async (importOriginal) => ({ + ...await importOriginal(), + getCurrentUser: () => ({ uid: 'alice' }), +})) + +const data: NodeData = { + id: 11, + source: 'https://cloud.example.com/remote.php/dav/files/alice/Photos/cat.jpg', + root: '/files/alice', + owner: 'bob', + mime: 'image/jpeg', + mtime: 1760000000, + crtime: null, + size: 42, + permissions: 27, + displayname: 'cat.jpg', + type: 'file', + attributes: { + etag: 'abc', + 'has-preview': true, + 'share-types': { 'share-type': [0] }, + }, +} + +describe('NodesApi', () => { + beforeEach(() => { + vi.mocked(axios.get).mockReset() + }) + + it('builds a node like a PROPFIND result', () => { + const node = dataToNode(data) + + expect(node).toBeInstanceOf(File) + expect(node.fileid).toBe(11) + expect(node.path).toBe('/Photos/cat.jpg') + expect(node.owner).toBe('bob') + expect(node.mtime).toEqual(new Date(1760000000 * 1000)) + expect(node.crtime).toBeUndefined() + expect(node.size).toBe(42) + expect(node.permissions).toBe(27) + expect(node.attributes).toMatchObject({ + etag: 'abc', + hasPreview: true, + 'has-preview': true, + 'share-types': { 'share-type': [0] }, + }) + }) + + it('builds folders', () => { + expect(dataToNode({ ...data, type: 'folder', mime: 'httpd/unix-directory', owner: null })).toBeInstanceOf(Folder) + }) + + it('splits the ids in requests of at most MAX_NODE_IDS', async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: [data] } } }) + const fileIds = Array.from({ length: MAX_NODE_IDS + 1 }, (_, index) => index) + + const nodes = await getNodesById(fileIds, { dir: '/Photos' }) + + expect(axios.get).toHaveBeenCalledTimes(2) + expect(vi.mocked(axios.get).mock.calls[0]![1]).toMatchObject({ params: { ids: fileIds.slice(0, MAX_NODE_IDS), dir: '/Photos' } }) + expect(vi.mocked(axios.get).mock.calls[1]![1]).toMatchObject({ params: { ids: [MAX_NODE_IDS], dir: '/Photos' } }) + expect(nodes).toHaveLength(2) + }) +}) diff --git a/apps/files/src/services/NodesApi.ts b/apps/files/src/services/NodesApi.ts new file mode 100644 index 0000000000000..5c3a83401d0af --- /dev/null +++ b/apps/files/src/services/NodesApi.ts @@ -0,0 +1,82 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import { File, Folder } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * A node as returned by the nodes API, the `FilesNode` type of the files app OpenAPI spec + */ +export interface NodeData { + id: number + source: string + root: string + owner: string | null + mime: string + mtime: number + crtime: number | null + size: number + permissions: number + displayname: string + type: 'file' | 'folder' + attributes: Record +} + +/** + * Maximum number of ids per request + */ +export const MAX_NODE_IDS = 100 + +/** + * Get the nodes with these file ids, with the same data as a PROPFIND. + * A file reachable at several paths is returned once per path, + * unknown or inaccessible ids are left out. + * + * @param fileIds - The file ids, any number: requests are split by `MAX_NODE_IDS` + * @param options - Options + * @param options.dir - Only return the paths within this folder, the folder itself included + * @param options.signal - Abort signal for the requests + */ +export async function getNodesById(fileIds: number[], options: { dir?: string, signal?: AbortSignal } = {}): Promise { + const nodes: INode[] = [] + for (let i = 0; i < fileIds.length; i += MAX_NODE_IDS) { + const { data } = await axios.get>(generateOcsUrl('apps/files/api/v1/nodes'), { + params: { ids: fileIds.slice(i, i + MAX_NODE_IDS), dir: options.dir }, + signal: options.signal, + }) + nodes.push(...data.ocs.data.map(dataToNode)) + } + return nodes +} + +/** + * Build a node from the nodes API data, with the attributes `resultToNode` gives a PROPFIND result + * + * @param data - The node data + */ +export function dataToNode(data: NodeData): INode { + const Node = data.type === 'folder' ? Folder : File + return new Node({ + id: data.id, + source: data.source, + root: data.root, + owner: data.owner ?? getCurrentUser()!.uid, + mime: data.mime, + mtime: new Date(data.mtime * 1000), + crtime: data.crtime ? new Date(data.crtime * 1000) : undefined, + size: data.size, + permissions: data.permissions, + displayname: data.displayname, + attributes: { + ...data.attributes, + hasPreview: data.attributes['has-preview'], + }, + }) +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index c67cea3ae5138..f8e7d93adf3a6 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -190,6 +190,7 @@ import FilesListVirtual from '../components/FilesListVirtual.vue' import { useFilesSorting } from '../composables/filesSorting.ts' import { useEnabledFileListActions } from '../composables/useFileListActions.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useLiveFolderRefresh } from '../composables/useLiveFolderRefresh.ts' import { useNewFileMenuActions } from '../composables/useNewFileMenuActions.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActiveStore } from '../store/active.ts' @@ -247,6 +248,7 @@ export default defineComponent({ const viewConfigStore = useViewConfigStore() const { isNarrow } = useFileListWidth() + useLiveFolderRefresh() const { directory, fileId } = useRouteParameters() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) diff --git a/package-lock.json b/package-lock.json index b3a527cda79bc..49686f9446b83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", + "@nextcloud/notify_push": "^1.4.0", "@nextcloud/password-confirmation": "^6.1.0", "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.2.0", @@ -2120,6 +2121,17 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/notify_push": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.4.0.tgz", + "integrity": "sha512-07UDgz1xLG9XABP8+mwQ2CsNWZu6lKzz0ErUA2HfE1ZfxXKiwVpo60t30y34UExGB9+Ok1nFaYU8fyJHncz9aQ==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/axios": "^2.6.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/event-bus": "^3.3.3" + } + }, "node_modules/@nextcloud/password-confirmation": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-6.1.0.tgz", diff --git a/package.json b/package.json index 120c500ea33fc..d9504acd8edb8 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", + "@nextcloud/notify_push": "^1.4.0", "@nextcloud/password-confirmation": "^6.1.0", "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.2.0",