feat(ui): Meldungen mit Rückgängig, dazu jsdom und ein Fehler in der letzten Behebung

Vorher bestätigte nichts irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.

Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.

Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.

**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:

Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann stehen, und die nächste echte Zurück-Geste wurde verschluckt: Statt die
Ansicht zu schließen, hätte man die Anwendung verlassen.

Jetzt wird erst im nächsten Durchlauf entschieden und geprüft, ob der eigene
Eintrag noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.

Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.

Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-18 14:05:12 +02:00
1 parent 7d45941c03
commit 94a803bf7a
9 files changed
+1131 -51

No files matched your search

+77 -6
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
filesApi, foldersApi, versionsApi, downloadZip, tagsChangedEvent,
filesApi, foldersApi, versionsApi, trashApi, downloadZip, tagsChangedEvent,
isOfficeFile,
type Correspondent, type FileItem, type FolderItem, type SearchHit, type Tag,
} from '../api/client'
@@ -11,6 +11,7 @@ import FilePreview, { canPreview, kindOf } from '../components/FilePreview'
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue from '../components/UploadQueue'
import OfficeEditor from '../components/OfficeEditor'
import { toast, fehlertext } from '../components/Toasts'
import FileToolbar, { type Sort, type ViewMode } from '../components/files/FileToolbar'
import FileDialogs, { type Dialog } from '../components/files/FileDialogs'
import { fileMenuItems, folderMenuItems } from '../components/files/menus'
@@ -389,7 +390,21 @@ export default function FileBrowser({
// Die Liste wird bewusst nur lokal angepasst (schneller), der belegte
// Speicher muss aber vom Server kommen – sonst bleibt der Balken stehen.
await onUserChanged?.()
} catch { /* die Datei bleibt stehen */ }
// Löschen ist bei uns der Papierkorb, Zurückholen ein einziger Aufruf.
// Deshalb kostet das Rückgängig hier fast nichts – und nimmt dem
// Löschen den Schrecken.
toast.undo(`Deleted „${file.name}"`, async () => {
try {
await trashApi.restore(file.id)
await load()
await onUserChanged?.()
} catch (err) {
toast.error(fehlertext(err, 'Could not restore the file'))
}
})
} catch (err) {
toast.error(fehlertext(err, `Could not delete „${file.name}"`))
}
}
/**
@@ -410,6 +425,21 @@ export default function FileBrowser({
lastPicked.current = null
await onUserChanged?.()
if (failed.length) await load()
if (failed.length) {
toast.error(`${failed.length} of ${ids.length} could not be deleted`)
}
if (ok.length) {
toast.undo(`Deleted ${ok.length} ${ok.length === 1 ? 'file' : 'files'}`, async () => {
// Einzeln zurück: Einen Sammelaufruf gibt es nicht, und bei einer
// Handvoll Dateien lohnt er auch keinen neuen Endpunkt.
const wieder = await Promise.allSettled(ok.map(id => trashApi.restore(id)))
const hin = wieder.filter(r => r.status === 'rejected').length
await load()
await onUserChanged?.()
if (hin) toast.error(`${hin} could not be restored`)
})
}
}
async function handleRename(file: FileItem, name: string) {
@@ -417,7 +447,18 @@ export default function FileBrowser({
try {
const updated = await filesApi.rename(file.id, name)
setFiles(f => f.map(x => x.id === file.id ? updated : x))
} catch { /* der alte Name bleibt stehen */ }
const alterName = file.name
toast.undo(`Renamed to „${name}"`, async () => {
try {
const zurueck = await filesApi.rename(file.id, alterName)
setFiles(f => f.map(x => x.id === file.id ? zurueck : x))
} catch (err) {
toast.error(fehlertext(err, 'Could not undo the rename'))
}
})
} catch (err) {
toast.error(fehlertext(err, 'Could not rename the file'))
}
}
async function handleCreateFolder(name: string) {
@@ -433,7 +474,18 @@ export default function FileBrowser({
try {
const updated = await foldersApi.rename(folder.id, name)
setFolders(f => f.map(x => x.id === folder.id ? updated : x))
} catch { /* der alte Name bleibt stehen */ }
const alterName = folder.name
toast.undo(`Renamed to „${name}"`, async () => {
try {
const zurueck = await foldersApi.rename(folder.id, alterName)
setFolders(f => f.map(x => x.id === folder.id ? zurueck : x))
} catch (err) {
toast.error(fehlertext(err, 'Could not undo the rename'))
}
})
} catch (err) {
toast.error(fehlertext(err, 'Could not rename the folder'))
}
}
async function handleDeleteFolder(folder: FolderItem) {
@@ -442,15 +494,34 @@ export default function FileBrowser({
await foldersApi.delete(folder.id)
setFolders(f => f.filter(x => x.id !== folder.id))
await onUserChanged?.()
} catch { /* der Ordner bleibt stehen */ }
// **Kein Rückgängig.** Die Dateien darin wandern in den Papierkorb, der
// Ordner selbst wird wirklich gelöscht – ein „Undo" müsste ihn neu
// anlegen und alles zurücksortieren. Ein Knopf, der etwas anderes tut
// als er verspricht, wäre schlimmer als keiner; deshalb sagt die
// Meldung stattdessen, wo die Dateien geblieben sind.
toast.info(`Deleted „${folder.name}" – its files are in the trash`)
} catch (err) {
toast.error(fehlertext(err, `Could not delete „${folder.name}"`))
}
}
async function handleMoveFile(file: FileItem, targetFolderId: string | null) {
setDialog(null)
try {
const alterOrdner = file.folder_id ?? null
await filesApi.move(file.id, targetFolderId)
await reload()
} catch { /* silent */ }
toast.undo(`Moved „${file.name}"`, async () => {
try {
await filesApi.move(file.id, alterOrdner)
await reload()
} catch (err) {
toast.error(fehlertext(err, 'Could not move it back'))
}
})
} catch (err) {
toast.error(fehlertext(err, `Could not move „${file.name}"`))
}
}
async function handleMoveFolder(folder: FolderItem, targetParentId: string | null) {