diff --git a/lib/components/FilePicker/FilePicker.spec.ts b/lib/components/FilePicker/FilePicker.spec.ts index e83e96837..3bfed8215 100644 --- a/lib/components/FilePicker/FilePicker.spec.ts +++ b/lib/components/FilePicker/FilePicker.spec.ts @@ -96,4 +96,18 @@ describe('FilePicker', () => { // The unpickable folder must not be passed as a selected node expect(nodes).toEqual([]) }) + + it('disables confirmation when the loaded folder does not match the displayed path', () => { + const wrapper = shallowMount(FilePicker, { + props: { + name: 'Test picker', + path: '/Destination', + allowPickDirectory: true, + buttons: [{ label: 'Pick', callback: () => {} }], + }, + }) + + const buttons = wrapper.getComponent({ name: 'NcDialog' }).props('buttons') as Array<{ disabled: boolean }> + expect(buttons[0]!.disabled).toBe(true) + }) }) diff --git a/lib/components/FilePicker/FilePicker.vue b/lib/components/FilePicker/FilePicker.vue index 3807f8a0c..49fa5bdf1 100644 --- a/lib/components/FilePicker/FilePicker.vue +++ b/lib/components/FilePicker/FilePicker.vue @@ -241,7 +241,9 @@ const dialogButtons = computed(() => { return buttons.map((button) => ({ ...button, - disabled: button.disabled || isLoading.value, + disabled: button.disabled || isLoading.value + || (props.allowPickDirectory && currentView.value === 'files' + && (!currentFolder.value || currentFolder.value.path !== currentPath.value)), callback: () => { // lock default close handling isHandlingCallback = true diff --git a/lib/composables/dav.race.spec.ts b/lib/composables/dav.race.spec.ts new file mode 100644 index 000000000..92ec95805 --- /dev/null +++ b/lib/composables/dav.race.spec.ts @@ -0,0 +1,135 @@ +/** + * SPDX-FileCopyrightText: 2026 Josh Duffy + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFolder, INode } from '@nextcloud/files' + +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' +import { useDAVFiles } from './dav.ts' + +const mocks = vi.hoisted(() => ({ nodes: vi.fn(), recent: vi.fn(), favorites: vi.fn() })) +vi.mock('@nextcloud/files/dav', () => ({ + getClient: () => ({}), + defaultRootPath: '/files/test-user', + getFavoriteNodes: mocks.favorites, +})) +vi.mock('../utils/dav.ts', () => ({ + getNodes: mocks.nodes, + getRecentNodes: mocks.recent, + getFile: vi.fn(), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + return { promise, resolve, reject } +} + +const folder = (path: string) => ({ path, type: 'folder', permissions: 31 }) as IFolder +const response = (path: string) => ({ folder: folder(path), contents: [folder(`${path}/child`)] as INode[] }) +type Response = ReturnType +let wrapper: ReturnType | undefined + +function setup() { + const path = ref('/Source') + const view = ref<'files' | 'recent' | 'favorites'>('files') + let state!: ReturnType + wrapper = mount(defineComponent({ + setup() { + state = useDAVFiles(view, path) + return () => h('div') + }, + })) + return { path, view, state } +} + +async function settle() { + await Promise.resolve() + await Promise.resolve() + await nextTick() +} + +beforeEach(() => vi.resetAllMocks()) +afterEach(() => wrapper?.unmount()) + +describe('DAV listing ownership', () => { + it('ignores an older successful response after the current destination completes', async () => { + const old = deferred() + const latest = deferred() + mocks.nodes.mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise) + const { path, state } = setup() + path.value = '/Destination' + await nextTick() + latest.resolve(response('/Destination')) + await settle() + old.resolve(response('/Source')) + await settle() + expect(state.folder.value?.path).toBe('/Destination') + expect(state.files.value[0]?.path).toBe('/Destination/child') + }) + + it('does not let an old cancellation clear the current loading state', async () => { + const old = deferred() + const pending = deferred() + const third = deferred() + mocks.nodes.mockReturnValueOnce(old.promise).mockReturnValueOnce(pending.promise).mockReturnValueOnce(third.promise) + const { path, state } = setup() + path.value = '/Destination' + await nextTick() + const signal = mocks.nodes.mock.calls[1]![0].signal as AbortSignal + old.reject(Object.assign(new Error('test cancellation'), { name: 'AbortError' })) + await settle() + expect(state.isLoading.value).toBe(true) + path.value = '/Elsewhere' + await nextTick() + expect(signal.aborted).toBe(true) + third.resolve(response('/Elsewhere')) + pending.resolve(response('/Destination')) + await settle() + expect(state.folder.value?.path).toBe('/Elsewhere') + }) + + it('invalidates the previous destination while a replacement load fails', async () => { + mocks.nodes.mockResolvedValueOnce(response('/Source')) + const { state } = setup() + await settle() + const failed = deferred() + mocks.nodes.mockReturnValueOnce(failed.promise) + const completion = state.loadFiles() + expect(state.folder.value).toBeNull() + expect(state.files.value).toEqual([]) + const caught = completion.catch((error) => error) + failed.reject(new Error('test HTTP 500')) + expect((await caught).message).toBe('test HTTP 500') + expect(state.folder.value).toBeNull() + expect(state.files.value).toEqual([]) + expect(state.isLoading.value).toBe(false) + }) + + it('keeps the latest view when an older view finishes last', async () => { + mocks.nodes.mockResolvedValueOnce(response('/Source')) + const recent = deferred() + const favorites = deferred() + mocks.recent.mockReturnValueOnce(recent.promise) + mocks.favorites.mockReturnValueOnce(favorites.promise) + const { view, state } = setup() + await settle() + view.value = 'recent' + await nextTick() + view.value = 'favorites' + await nextTick() + favorites.resolve([folder('/Favorite')]) + await settle() + recent.resolve([folder('/OldRecent')]) + await settle() + expect(state.folder.value).toBeNull() + expect(state.files.value[0]?.path).toBe('/Favorite') + }) +}) diff --git a/lib/composables/dav.ts b/lib/composables/dav.ts index a54e0edb5..e111d42b2 100644 --- a/lib/composables/dav.ts +++ b/lib/composables/dav.ts @@ -73,17 +73,23 @@ export function useDAVFiles( const thisAbortController = new AbortController() abortController = thisAbortController isLoading.value = true + folder.value = null + files.value = [] try { + let nextFiles: INode[] + let nextFolder: IFolder | null = null if (currentView.value === 'favorites') { - files.value = await getFavoriteNodes({ client, path: currentPath.value, signal: thisAbortController.signal }) - folder.value = null + nextFiles = await getFavoriteNodes({ client, path: currentPath.value, signal: thisAbortController.signal }) } else if (currentView.value === 'recent') { - files.value = await getRecentNodes({ client, signal: thisAbortController.signal }) - folder.value = null + nextFiles = await getRecentNodes({ client, signal: thisAbortController.signal }) } else { const content = await getNodes({ client, path: currentPath.value, signal: thisAbortController.signal }) - folder.value = content.folder - files.value = content.contents + nextFolder = content.folder + nextFiles = content.contents + } + if (abortController === thisAbortController) { + folder.value = nextFolder + files.value = nextFiles } } catch (error) { if (error instanceof Error && error.name === 'AbortError') {