From d9427f1d9e3638b8464ee6c0178236d4841c7fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:35:38 +0200 Subject: [PATCH] feat(files): refresh the files list from notify_push file changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When notify_push is available, the files list listens to the ids of changed files and updates the current folder in place. Only the changed children, new files and the folder itself are fetched, by id through the nodes API, scoped to the current folder. When the parent of the current folder changes, the folder is checked: if it moved the list follows it, if it was deleted the list goes to the closest remaining ancestor. A light listing of file ids and etags is compared instead when more than 200 children changed, when only the folder changed, or when shares changed in the root folder. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../composables/useLiveFolderRefresh.spec.ts | 358 ++++++++++++++++++ .../src/composables/useLiveFolderRefresh.ts | 280 ++++++++++++++ apps/files/src/services/Files.spec.ts | 43 +++ apps/files/src/services/Files.ts | 22 +- apps/files/src/services/NodesApi.spec.ts | 77 ++++ apps/files/src/services/NodesApi.ts | 82 ++++ apps/files/src/views/FilesList.vue | 2 + package-lock.json | 12 + package.json | 1 + 9 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 apps/files/src/composables/useLiveFolderRefresh.spec.ts create mode 100644 apps/files/src/composables/useLiveFolderRefresh.ts create mode 100644 apps/files/src/services/Files.spec.ts create mode 100644 apps/files/src/services/NodesApi.spec.ts create mode 100644 apps/files/src/services/NodesApi.ts 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",