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:
2026-10-08 22:43:32 +02:00
parent 08abfef8e2
commit 486819f80b
3 changed files with 113 additions and 1 deletions
@@ -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 };
} }