fix(nextcloud-files): WR-07 Merkzettel angelegter Ordner gilt nur fuer eine Ablage
- der Merkzettel wird geleert, wenn neu abgelegt wird und nichts mehr laeuft, nach jedem Loeschen, Verschieben und Umbenennen in der Ansicht und wenn ein Hochladen mit pathConflict/notFound scheitert (dann legt "Erneut versuchen" den Ordner wieder an) - innerhalb einer Ablage wird jeder Ordner weiter nur einmal angelegt - Hook-Tests fuer alle drei Wege Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -475,6 +475,7 @@ export function FileBrowser({
|
|||||||
|
|
||||||
const confirmDelete = async (list: NcEntry[]) => {
|
const confirmDelete = async (list: NcEntry[]) => {
|
||||||
const { failed, reason } = await runEach(list, (e) => deleteEntry(e.path));
|
const { failed, reason } = await runEach(list, (e) => deleteEntry(e.path));
|
||||||
|
transfers.forgetFolders();
|
||||||
setDialog(null);
|
setDialog(null);
|
||||||
dispatch({ type: 'clear' });
|
dispatch({ type: 'clear' });
|
||||||
await load(pathRef.current, { quiet: true });
|
await load(pathRef.current, { quiet: true });
|
||||||
@@ -486,6 +487,7 @@ export function FileBrowser({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmMove = async (list: NcEntry[], targetDir: string) => {
|
const confirmMove = async (list: NcEntry[], targetDir: string) => {
|
||||||
|
transfers.forgetFolders();
|
||||||
if (list.length === 1) {
|
if (list.length === 1) {
|
||||||
// Ein einzelner Eintrag: Fehler bleiben im Dialog sichtbar.
|
// Ein einzelner Eintrag: Fehler bleiben im Dialog sichtbar.
|
||||||
await moveEntry(list[0].path, join(targetDir, list[0].name));
|
await moveEntry(list[0].path, join(targetDir, list[0].name));
|
||||||
@@ -515,6 +517,7 @@ export function FileBrowser({
|
|||||||
|
|
||||||
const submitRename = async (entry: NcEntry, name: string) => {
|
const submitRename = async (entry: NcEntry, name: string) => {
|
||||||
const to = join(parent(entry.path), name);
|
const to = join(parent(entry.path), name);
|
||||||
|
transfers.forgetFolders();
|
||||||
await moveEntry(entry.path, to);
|
await moveEntry(entry.path, to);
|
||||||
setDialog(null);
|
setDialog(null);
|
||||||
focusAfterLoad.current = to;
|
focusAfterLoad.current = to;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NextcloudFilesRequestError } from '@/lib/nextcloud-files-api';
|
||||||
|
import { useTransfers } from './use-transfers';
|
||||||
|
|
||||||
|
const mockCreate = vi.fn();
|
||||||
|
const mockUpload = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createFolder: (...a: unknown[]) => mockCreate(...a),
|
||||||
|
listFolder: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('@/lib/nextcloud-files-upload', () => ({
|
||||||
|
uploadFile: (...a: unknown[]) => mockUpload(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const file = (name: string) => new File(['x'], name);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreate.mockReset().mockResolvedValue({ path: '/A' });
|
||||||
|
mockUpload.mockReset().mockResolvedValue({ path: '/A/a.txt', size: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTransfers — Ordner-Merkzettel (WR-07)', () => {
|
||||||
|
it('eine neue Ablage nach dem Ende der vorigen legt den Ordner erneut an (er kann inzwischen geloescht sein)', async () => {
|
||||||
|
const { result } = renderHook(() => useTransfers({ quotaAvailable: null }));
|
||||||
|
act(() => result.current.enqueue([{ file: file('a.txt'), relativeDir: 'A' }], '/'));
|
||||||
|
await waitFor(() => expect(result.current.transfers[0]?.status).toBe('done'));
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCreate).toHaveBeenLastCalledWith('/A');
|
||||||
|
|
||||||
|
act(() => result.current.enqueue([{ file: file('b.txt'), relativeDir: 'A' }], '/'));
|
||||||
|
await waitFor(() => expect(result.current.transfers[1]?.status).toBe('done'));
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('innerhalb einer Ablage wird jeder Ordner nur einmal angelegt', async () => {
|
||||||
|
const { result } = renderHook(() => useTransfers({ quotaAvailable: null }));
|
||||||
|
act(() =>
|
||||||
|
result.current.enqueue(
|
||||||
|
[
|
||||||
|
{ file: file('a.txt'), relativeDir: 'A' },
|
||||||
|
{ file: file('b.txt'), relativeDir: 'A' },
|
||||||
|
{ file: file('c.txt'), relativeDir: 'A' },
|
||||||
|
],
|
||||||
|
'/',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.transfers.every((t) => t.status === 'done')).toBe(true),
|
||||||
|
);
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgetFolders und ein Hochladen mit pathConflict leeren den Merkzettel', async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const hold = new Promise<void>((r) => {
|
||||||
|
release = r;
|
||||||
|
});
|
||||||
|
mockUpload
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
await hold;
|
||||||
|
return { path: '/A/a.txt', size: 1 };
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new NextcloudFilesRequestError(409, 'pathConflict', 'fehlt'))
|
||||||
|
.mockResolvedValue({ path: '/A/c.txt', size: 1 });
|
||||||
|
const { result } = renderHook(() => useTransfers({ quotaAvailable: null }));
|
||||||
|
// Die erste Datei laeuft noch: die zweite Ablage nutzt den Merkzettel.
|
||||||
|
act(() => result.current.enqueue([{ file: file('a.txt'), relativeDir: 'A' }], '/'));
|
||||||
|
await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(1));
|
||||||
|
act(() => result.current.enqueue([{ file: file('b.txt'), relativeDir: 'A' }], '/'));
|
||||||
|
await waitFor(() => expect(result.current.transfers[1]?.status).toBe('error'));
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
// pathConflict: "Erneut versuchen" legt den Ordner wieder an.
|
||||||
|
act(() => result.current.retry(result.current.transfers[1].id));
|
||||||
|
await waitFor(() => expect(result.current.transfers[1]?.status).toBe('done'));
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
act(() => result.current.forgetFolders());
|
||||||
|
act(() => result.current.enqueue([{ file: file('c.txt'), relativeDir: 'A' }], '/'));
|
||||||
|
await waitFor(() => expect(result.current.transfers[2]?.status).toBe('done'));
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(3);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,12 @@ import { freeName, join } from './paths';
|
|||||||
* Dateien gleichzeitig, jede mit eigenem Abbruch. Ein vergebener Name endet nicht
|
* Dateien gleichzeitig, jede mit eigenem Abbruch. Ein vergebener Name endet nicht
|
||||||
* als Fehler, sondern als Rueckfrage (Ersetzen, Beide behalten, Ueberspringen).
|
* als Fehler, sondern als Rueckfrage (Ersetzen, Beide behalten, Ueberspringen).
|
||||||
* Was nicht in den freien Speicher passt, wird gar nicht erst gesendet.
|
* Was nicht in den freien Speicher passt, wird gar nicht erst gesendet.
|
||||||
|
*
|
||||||
|
* Angelegte Ordner werden nur fuer eine Ablage gemerkt (WR-07): Der Merkzettel wird
|
||||||
|
* geleert, sobald nichts mehr laeuft und neu abgelegt wird, nach jedem Loeschen,
|
||||||
|
* Verschieben oder Umbenennen in der Ansicht (`forgetFolders`) und wenn ein Hochladen
|
||||||
|
* mit "Zielordner fehlt" scheitert — sonst liefe ein neu abgelegter, inzwischen
|
||||||
|
* geloeschter Ordner gleichen Namens ohne MKCOL ins Leere.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MAX_PARALLEL = 2;
|
export const MAX_PARALLEL = 2;
|
||||||
@@ -174,6 +180,13 @@ export function useTransfers(options: UseTransfersOptions) {
|
|||||||
patch(id, { status: 'done', loaded: item.size });
|
patch(id, { status: 'done', loaded: item.size });
|
||||||
optsRef.current.onFolderChanged?.(item.targetDir);
|
optsRef.current.onFolderChanged?.(item.targetDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof NextcloudFilesRequestError &&
|
||||||
|
(err.code === 'pathConflict' || err.code === 'notFound')
|
||||||
|
) {
|
||||||
|
// Der Zielordner fehlt: beim naechsten Versuch die Ordner wieder anlegen.
|
||||||
|
folders.current.clear();
|
||||||
|
}
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
// Abgebrochen: die Zeile ist bereits entfernt.
|
// Abgebrochen: die Zeile ist bereits entfernt.
|
||||||
} else if (err instanceof NextcloudFilesRequestError && err.code === 'nameTaken') {
|
} else if (err instanceof NextcloudFilesRequestError && err.code === 'nameTaken') {
|
||||||
@@ -217,6 +230,8 @@ export function useTransfers(options: UseTransfersOptions) {
|
|||||||
|
|
||||||
const enqueue = useCallback(
|
const enqueue = useCallback(
|
||||||
(files: readonly DroppedFile[], baseDir: string, emptyFolders: readonly string[] = []) => {
|
(files: readonly DroppedFile[], baseDir: string, emptyFolders: readonly string[] = []) => {
|
||||||
|
// Neue Ablage, waehrend nichts laeuft: Ordner von frueher koennen inzwischen geloescht sein.
|
||||||
|
if (!listRef.current.some(isActive)) folders.current.clear();
|
||||||
for (const folder of emptyFolders) {
|
for (const folder of emptyFolders) {
|
||||||
void ensureFolders(baseDir, folder).catch(() => undefined);
|
void ensureFolders(baseDir, folder).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -254,6 +269,11 @@ export function useTransfers(options: UseTransfersOptions) {
|
|||||||
[ensureFolders],
|
[ensureFolders],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Nach Loeschen, Verschieben oder Umbenennen: gemerkte Ordner gelten nicht mehr. */
|
||||||
|
const forgetFolders = useCallback(() => {
|
||||||
|
folders.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancel = useCallback((id: string) => {
|
const cancel = useCallback((id: string) => {
|
||||||
const it = internals.current.get(id);
|
const it = internals.current.get(id);
|
||||||
it?.controller?.abort();
|
it?.controller?.abort();
|
||||||
@@ -319,5 +339,5 @@ export function useTransfers(options: UseTransfersOptions) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { transfers, enqueue, cancel, retry, resolve, clearFinished };
|
return { transfers, enqueue, cancel, retry, resolve, clearFinished, forgetFolders };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user