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

+57
View File
@@ -1468,6 +1468,63 @@ das war die Asymmetrie, über die man stolperte.
---
## Meldungen mit Rückgängig (18.09.2026)
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.
- [ ] Datei löschen → Meldung mit „Undo", Datei ist zurück 🔎
- [ ] Mehrere löschen → eine Meldung, „Undo" holt alle zurück
- [ ] Umbenennen → „Undo" stellt den alten Namen her
- [ ] Verschieben → „Undo" bringt die Datei an den alten Ort
- [ ] Ordner löschen → **kein** „Undo", stattdessen der Hinweis auf den
Papierkorb (der Ordner selbst ist wirklich weg)
- [ ] Ohne Schreibrecht löschen → rote Meldung mit dem Satz des Servers
- [ ] Netz trennen, dann löschen → „No connection to the server."
- [ ] Meldung verschwindet nach 4 s, mit Knopf erst nach 10
- [ ] Fünfte Meldung verdrängt die erste (höchstens vier stehen)
- [ ] Betriebssystem auf „Bewegung reduzieren" → keine Einblendung
## Der Zurück-Fehler und was der Test **nicht** kann (16.–18.09.2026)
Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu
öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; deren Aufräumer rief
`history.back()`, das verzögert eintraf und den Eintrag verbrauchte, den der
Editor gerade angelegt hatte.
**Die erste Behebung war selbst fehlerhaft** und ist erst durch die neuen
jsdom-Tests aufgefallen: Sie zählte selbst ausgelöste Sprünge mit. Das setzt
voraus, dass jedes `back()` auch ein `popstate` nach sich zieht – und das gilt
nicht. Folgt im selben Durchlauf ein `pushState`, verwirft der Browser den
Sprung. Der Zähler blieb dann für immer 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 überhaupt noch obenauf liegt. Nur dann kommt es zum Sprung – und
dann folgt das `popstate` garantiert.
- [x] Vier jsdom-Tests in `useBackClose.test.tsx`, unter Mutation nachgewiesen:
Der Schalter bleibt stehen → der Übergangstest wird rot.
> **Was diese Tests nicht leisten.** Den ursprünglichen Fehler bilden sie
> **nicht** ab. jsdom verwirft einen noch nicht ausgeführten Sprung, sobald
> `pushState` folgt – das verhängnisvolle `popstate` entsteht dort also nie.
> Nachgeprüft, indem die alte, kaputte Fassung wieder eingesetzt wurde: **alle
> Tests blieben grün.** Chrome auf dem Telefon verhält sich anders, und dort
> war der Fehler zu sehen.
>
> Wer diesen Fall absichern will, braucht einen echten Browser (Playwright).
> Bis dahin ist der Beleg ein Mensch mit einem Telefon.
Seit dem 18.09.2026 ist **jsdom** in den devDependencies. Die Vitest-Umgebung
bleibt `node` (schnell); wer ein DOM braucht, schreibt
`// @vitest-environment jsdom` an den Anfang seiner Datei.
---
## Absender / Korrespondent (23.08.2026)
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
+533
View File
@@ -21,6 +21,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^26.1.0",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
@@ -41,6 +42,27 @@
"ajv": ">=8"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1401,6 +1423,121 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2955,6 +3092,16 @@
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -3308,12 +3455,77 @@
"node": ">=8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3378,6 +3590,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3488,6 +3707,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4043,6 +4275,60 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -4276,6 +4562,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4476,6 +4769,83 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4863,6 +5233,13 @@
"node": ">=18"
}
},
"node_modules/nwsapi": {
"version": "2.2.27",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
"dev": true,
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4935,6 +5312,19 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5291,6 +5681,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safe-array-concat": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
@@ -5340,6 +5737,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5689,6 +6106,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
@@ -5789,6 +6213,39 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@@ -6201,11 +6658,48 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -6513,6 +7007,45 @@
"workbox-core": "7.4.1"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -24,6 +24,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^26.1.0",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
+6
View File
@@ -4,6 +4,7 @@ import type { FolderItem, User } from '../api/client'
import { useNavHistory, type View } from '../hooks/useNavHistory'
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
import { useBackClose } from '../hooks/useBackClose'
import { Toaster } from './Toasts'
import Sidebar from './Sidebar'
import FileBrowser from '../pages/FileBrowser'
import TrashPage from '../pages/TrashPage'
@@ -95,6 +96,11 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
onOpenSidebar: () => setSidebarOpen(true),
}}>
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
diese eine Anzeige. */}
<Toaster />
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { APIError } from '../api/client'
/**
* Kurze Rückmeldungen am unteren Rand – und, wo es geht, ein „Rückgängig".
*
* Vorher bestätigte **nichts** irgendetwas: Verschieben, Umbenennen, Teilen
* geschah, und die Oberfläche schwieg. Schlimmer noch das Scheitern – die
* Handler fingen ihre Fehler mit `catch { /* bleibt stehen *\/ }` ab. Wer eine
* Datei löschen wollte und keine Berechtigung hatte, sah exakt dasselbe wie
* jemand, bei dem es geklappt hat: nichts.
*
* **Kein Context, sondern Modulzustand.** Eine Meldung soll von überall
* abgesetzt werden können – auch aus `api/client.ts`, das keine Komponente
* ist und in keinem Anbieter steckt. Derselbe Weg wie bei `useBackClose`:
* ein Zustand im Modul, und die Anzeige hört zu.
*/
export type ToastTone = 'info' | 'error'
export interface ToastAction {
label: string
/** Läuft genau einmal; danach schließt die Meldung. */
run: () => void | Promise<void>
}
interface Toast {
id: number
text: string
tone: ToastTone
action?: ToastAction
}
let toasts: Toast[] = []
let seq = 0
const hoerer = new Set<(t: Toast[]) => void>()
/** Höchstens so viele gleichzeitig. Darüber stapelt sich Lärm, kein Inhalt. */
const maxSichtbar = 4
/**
* Wie lange eine Meldung steht.
*
* Mit Knopf deutlich länger: Man muss sie lesen, verstehen, *entscheiden* und
* dann treffen. Vier Sekunden reichen zum Lesen, nicht zum Entscheiden – ein
* „Rückgängig", das weg ist, bevor die Hand da war, ist schlimmer als keines,
* weil es die Erwartung geweckt hat.
*/
const dauerSchlicht = 4000
const dauerMitKnopf = 10000
function melden() {
for (const h of hoerer) h(toasts)
}
function schliessen(id: number) {
toasts = toasts.filter(t => t.id !== id)
melden()
}
function zeigen(text: string, tone: ToastTone, action?: ToastAction): number {
const id = ++seq
toasts = [...toasts, { id, text, tone, action }].slice(-maxSichtbar)
melden()
// Bewusst `setTimeout` statt `window.setTimeout`: Dieselbe Funktion, aber
// ohne `window` läuft der Zustandsteil dieser Datei auch im Test, der ohne
// DOM startet.
setTimeout(() => schliessen(id), action ? dauerMitKnopf : dauerSchlicht)
return id
}
export const toast = {
/** Es hat geklappt. */
info: (text: string) => zeigen(text, 'info'),
/** Es hat nicht geklappt – bleibt genauso lange stehen, das ist Absicht:
* Eine Fehlermeldung, die man festhalten will, steht in der Konsole. */
error: (text: string) => zeigen(text, 'error'),
/**
* Es hat geklappt, und man kann es zurücknehmen.
*
* Der eigentliche Grund, warum es diese Datei gibt. Löschen ist bei uns ein
* Papierkorb, Wiederherstellen ein einziger Aufruf – das Rückgängig kostet
* also fast nichts und nimmt dem Löschen den Schrecken.
*/
undo: (text: string, run: ToastAction['run'], label = 'Undo') =>
zeigen(text, 'info', { label, run }),
schliessen,
}
/** Einmal im Gerüst einhängen. */
export function Toaster() {
const [liste, setListe] = useState<Toast[]>(toasts)
const [laufend, setLaufend] = useState<number | null>(null)
useEffect(() => {
hoerer.add(setListe)
return () => { hoerer.delete(setListe) }
}, [])
if (liste.length === 0) return null
return (
<div
// `polite`, nicht `assertive`: Eine Bestätigung darf warten, bis der
// Vorleser den laufenden Satz beendet hat.
aria-live="polite"
style={{
position: 'fixed', zIndex: 300,
left: 'calc(12px + var(--safe-left))', right: 12,
bottom: 'calc(12px + var(--safe-bottom))',
display: 'flex', flexDirection: 'column', gap: 8,
alignItems: 'center',
// Der Bereich selbst fängt keine Klicks – darunter liegt die
// Dateiliste, und die soll bedienbar bleiben. Nur die Meldungen
// selbst nehmen wieder an.
pointerEvents: 'none',
}}
>
{liste.map(t => (
<div
key={t.id}
className="aether-toast"
style={{
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', gap: 14,
maxWidth: 480, width: 'fit-content',
padding: '11px 14px',
borderRadius: 10,
background: 'var(--color-surface)',
border: `1px solid ${t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-border)'}`,
boxShadow: '0 10px 30px rgba(0,0,0,0.35)',
fontSize: 13,
color: t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-text)',
}}
>
<span style={{ flex: 1, minWidth: 0 }}>{t.text}</span>
{t.action && (
<button
disabled={laufend === t.id}
onClick={async () => {
setLaufend(t.id)
try {
await t.action!.run()
} finally {
// Auch nach einem Fehler schließen: Der Aufrufer meldet ihn
// selbst, und eine stehengebliebene Meldung mit totem Knopf
// wäre die schlechtere Antwort.
setLaufend(null)
schliessen(t.id)
}
}}
style={{
flexShrink: 0,
border: 'none', background: 'none',
color: 'var(--color-primary)',
font: 'inherit', fontWeight: 600,
cursor: laufend === t.id ? 'default' : 'pointer',
padding: '2px 4px',
opacity: laufend === t.id ? 0.5 : 1,
}}
>
{t.action.label}
</button>
)}
<button
onClick={() => schliessen(t.id)}
aria-label="Dismiss"
style={{
flexShrink: 0,
border: 'none', background: 'none', cursor: 'pointer',
color: 'var(--color-text-muted)', display: 'flex', padding: 2,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)
}
/**
* Aus einem Fehler einen Satz machen, den man lesen kann.
*
* Der Server schreibt seine Meldungen selbst und in ganzen Sätzen („You only
* have read access to this file") – die ist besser als alles, was wir hier
* erfinden könnten, und wird deshalb bevorzugt. Der Rückfalltext greift nur,
* wenn gar nichts da ist.
*
* Zwei Fälle bekommen einen eigenen Satz, weil die Rohfassung nichts sagt:
* `403` ohne Text, und der Abbruch ohne Antwort (Status 0) – „Unknown error"
* hilft niemandem, „keine Verbindung" schon.
*/
export function fehlertext(err: unknown, rückfall: string): string {
if (err instanceof APIError) {
if (err.status === 0) return 'No connection to the server.'
if (err.message && err.message !== 'Unknown error') return err.message
if (err.status === 403) return 'You do not have permission for that.'
return rückfall
}
return err instanceof Error && err.message ? err.message : rückfall
}
+155
View File
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { useState } from 'react'
import { render, screen, act, cleanup } from '@testing-library/react'
import { describe, it, expect, afterEach } from 'vitest'
import { useBackClose } from './useBackClose'
/**
* **Was diese Tests abdecken – und was ausdrücklich nicht.**
*
* Abgedeckt ist die Rangfolge: Eine Zurück-Geste schließt genau die oberste
* Ansicht, und ein selbst ausgelöster Sprung schließt gar nichts. Der zweite
* Punkt ist nicht theoretisch – er hat hier einen echten Fehler gefangen: Die
* erste Fassung der Behebung zählte selbst ausgelöste Sprünge mit, und der
* Zähler blieb stehen, wenn ein Sprung gar nicht ausgeführt wurde. Die
* **nächste** echte Geste wäre dann verschluckt worden, und statt die Ansicht
* zu schließen, hätte man die Anwendung verlassen.
*
* **Nicht abgedeckt ist der Fehler, der den Anlass gab** (16.09.2026): Aus der
* PDF-Ansicht führte „Edit" in den Editor, und statt ihn zu öffnen, schloss
* sich die Datei. Ursache war ein `history.back()` aus dem Aufräumer der alten
* Ansicht, das verzögert eintraf und den Eintrag der neuen verbrauchte.
*
* Nachgestellt bekommt man das hier **nicht**: jsdom verwirft einen noch nicht
* ausgeführten Sprung, sobald `pushState` folgt – das verhängnisvolle
* `popstate` entsteht dort also nie. Nachgeprüft, indem die alte, kaputte
* Fassung wieder eingesetzt wurde: Alle Tests blieben grün. Chrome auf dem
* Telefon verhält sich anders, und dort war der Fehler zu sehen.
*
* Wer diesen Fall absichern will, braucht einen echten Browser (Playwright).
* Bis dahin ist der Beleg ein Mensch mit einem Telefon – festgehalten in
* TESTING.md.
*/
/** Wartet, bis der Browser sein `popstate` nachgereicht hat. */
async function verlaufSetztSich() {
await act(async () => {
await new Promise(r => setTimeout(r, 60))
})
}
function Ansicht({ name, onClose }: { name: string; onClose: () => void }) {
useBackClose({ active: true, onClose })
return <div>{name}</div>
}
describe('useBackClose', () => {
// Testing Library hängt die Ansichten nach jedem Test aus. Deren Aufräumer
// stößt einen Verlaufsschritt an, der erst danach ankommt – und im nächsten
// Test ein fremdes `popstate` wäre. Also selbst abräumen und abwarten:
// erst die Aushäng-Effekte laufen lassen, dann das `popstate` einsammeln.
afterEach(async () => {
cleanup()
await new Promise(r => setTimeout(r, 0))
await new Promise(r => setTimeout(r, 80))
})
it('schließt auf die Zurück-Geste des Nutzers', async () => {
function Eine() {
const [offen, setOffen] = useState(true)
return offen ? <Ansicht name="Offen" onClose={() => setOffen(false)} /> : <div>zu</div>
}
render(<Eine />)
expect(screen.getByText('Offen')).toBeTruthy()
await act(async () => { window.history.back() })
await verlaufSetztSich()
expect(screen.getByText('zu')).toBeTruthy()
})
it('schließt immer nur die oberste von zwei Ansichten', async () => {
function Zwei() {
const [unten, setUnten] = useState(true)
const [oben, setOben] = useState(true)
return (
<>
{unten && <Ansicht name="unten" onClose={() => setUnten(false)} />}
{oben && <Ansicht name="oben" onClose={() => setOben(false)} />}
</>
)
}
render(<Zwei />)
await act(async () => { window.history.back() })
await verlaufSetztSich()
// Eine Geste, eine Ansicht. Ohne die Rangfolge im Stapel fielen beide auf
// einmal weg – und der Nutzer stünde zwei Ebenen tiefer, als er gewischt
// hat.
expect(screen.queryByText('oben')).toBeNull()
expect(screen.queryByText('unten')).toBeTruthy()
await act(async () => { window.history.back() })
await verlaufSetztSich()
expect(screen.queryByText('unten')).toBeNull()
})
it('verschluckt die Geste nicht, nachdem eine Ansicht in eine andere überging', async () => {
// Der Übergang Vorschau → „Edit" → Editor: Die alte Ansicht geht, die neue
// kommt im selben Rendern. Ihr Aufräumer darf den Verlauf dann **nicht**
// anfassen – und vor allem nichts merken, was er später verschlucken
// würde. Genau daran ist die erste Fassung gescheitert: Der Schalter blieb
// stehen, und die nächste echte Geste verpuffte.
function Wechsel() {
const [wo, setWo] = useState<'vorschau' | 'editor' | 'nichts'>('vorschau')
return (
<>
{wo === 'vorschau' && <Ansicht name="Vorschau" onClose={() => setWo('nichts')} />}
{wo === 'editor' && <Ansicht name="Editor" onClose={() => setWo('nichts')} />}
<button onClick={() => setWo('editor')}>edit</button>
</>
)
}
render(<Wechsel />)
await act(async () => { screen.getByText('edit').click() })
await verlaufSetztSich()
expect(screen.queryByText('Editor')).toBeTruthy()
// Und jetzt die Zurück-Geste. Sie muss ankommen.
await act(async () => { window.history.back() })
await verlaufSetztSich()
expect(screen.queryByText('Editor')).toBeNull()
})
it('verschluckt die nächste Geste nicht, wenn eine Ansicht per Knopf ging', async () => {
// Genau der Fall, an dem die erste Fassung scheiterte: Die obere Ansicht
// geht über einen Knopf zu – und danach muss eine echte Geste wieder
// greifen, statt vom Aufräumer der oberen verschluckt zu werden.
function Zwei() {
const [unten, setUnten] = useState(true)
const [oben, setOben] = useState(true)
return (
<>
{unten && <Ansicht name="unten" onClose={() => setUnten(false)} />}
{oben && <Ansicht name="oben" onClose={() => setOben(false)} />}
<button onClick={() => setOben(false)}>schliessen</button>
</>
)
}
render(<Zwei />)
await act(async () => { screen.getByText('schliessen').click() })
await verlaufSetztSich()
expect(screen.queryByText('oben')).toBeNull()
expect(screen.queryByText('unten')).toBeTruthy()
await act(async () => { window.history.back() })
await verlaufSetztSich()
expect(screen.queryByText('unten')).toBeNull()
})
})
+74 -45
View File
@@ -22,26 +22,66 @@ import { useEffect, useRef } from 'react'
* und nur der oberste reagiert.
*/
/** Kennungen der offenen Ansichten, unterste zuerst. */
let stack: number[] = []
/** Die offenen Ansichten, unterste zuerst – mit ihrem Schließen-Aufruf. */
let stack: { id: number; close: () => boolean | void }[] = []
let seq = 0
/**
* Wie viele `popstate` noch von **uns selbst** stammen und nicht vom Nutzer.
* Ein von **uns** ausgelöster Verlaufsschritt ist unterwegs.
*
* Der Aufräumer unten ruft `history.back()`, wenn eine Ansicht über einen
* Knopf geschlossen wurde – ihr Eintrag muss ja weg. Nur wirkt `back()` nicht
* sofort, sondern erst im nächsten Durchlauf. Öffnet in derselben Runde eine
* *andere* Ansicht (Vorschau → „Edit" → Editor), liegt deren frischer Eintrag
* dann schon obenauf – und das verspätete `back()` verbraucht ihn. Der Editor
* bekam ein `popstate`, hielt es für die Zurück-Geste und schloss sich sofort
* wieder: Aus „Edit" wurde „Datei zu".
* Der Aufräumer unten geht zurück, wenn eine Ansicht über einen Knopf
* geschlossen wurde – ihr Eintrag muss ja weg. Das daraus folgende `popstate`
* ist keine Geste des Nutzers und darf nichts schließen.
*
* Deshalb zählen wir die selbst ausgelösten Schritte mit und verschlucken
* genau so viele `popstate`. Die Zurück-Geste des Nutzers erkennt man daran,
* dass keiner offen ist.
* **Warum ein Schalter und kein Zähler.** Der erste Versuch zählte die selbst
* ausgelösten Schritte mit. Das setzt voraus, dass jedes `back()` auch ein
* `popstate` nach sich zieht – und genau das gilt nicht: Ruft im selben
* Durchlauf jemand `pushState` (die neue Ansicht legt ihren Eintrag an), wird
* der noch nicht ausgeführte Sprung verworfen. Der Zähler blieb dann für immer
* stehen, und die **nächste** echte Zurück-Geste wurde verschluckt – der
* Nutzer verließ die Anwendung, statt die Ansicht zu schließen. Nachgestellt
* in useBackClose.test.tsx.
*
* Deshalb wird unten erst im nächsten Durchlauf entschieden **und** geprüft,
* ob der eigene Eintrag überhaupt noch obenauf liegt. Nur dann kommt es zum
* Sprung – und dann folgt das `popstate` garantiert.
*/
let eigeneSchritte = 0
let unterwegs = false
/**
* **Ein Zuhörer fürs ganze Modul, nicht einer je Ansicht.**
*
* Hängt er an der Ansicht, entfernt ihn deren Aufräumer – und zwar bevor der
* Sprung ausgeführt ist, den derselbe Aufräumer auslöst. War danach keine
* weitere Ansicht offen, hörte niemand mehr zu. Ein Zuhörer, der so lange
* lebt wie die Seite, hat dieses Loch nicht und kostet nichts: ohne offene
* Ansicht kehrt er sofort um.
*/
let lauscht = false
function onPop() {
// Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt
// gehört zu einer Ansicht, die bereits weg ist.
if (unterwegs) {
unterwegs = false
return
}
// Nur die oberste reagiert. Eine tiefer liegende hat ihren eigenen
// Zurück-Schritt noch vor sich.
const oben = stack.at(-1)
if (!oben) return
stack = stack.slice(0, -1)
if (oben.close() === false) {
stack = [...stack, oben]
window.history.pushState({ ...window.history.state, [stateKey]: oben.id }, '')
}
}
function lauschen() {
if (lauscht || typeof window === 'undefined') return
window.addEventListener('popstate', onPop)
lauscht = true
}
/** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim
* Zurückkommen wiederzuerkennen. */
@@ -67,48 +107,37 @@ export function useBackClose({ active, onClose }: Options) {
useEffect(() => {
if (!active) return
lauschen()
const id = ++seq
stack.push(id)
const eintrag = { id, close: () => close.current() }
stack = [...stack, eintrag]
// Den bisherigen State mitnehmen: Darin steckt die Ansicht der Anwendung
// (siehe useNavHistory). Ginge er verloren, stünde man nach dem
// Schließen in der Wurzel statt im Ordner.
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
function push() {
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
}
function onPop() {
// Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt
// gehört zu einer Ansicht, die bereits weg ist.
if (eigeneSchritte > 0) {
eigeneSchritte--
return
}
// Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen
// eigenen Zurück-Schritt noch vor sich.
if (stack.at(-1) !== id) return
stack = stack.filter(x => x !== id)
if (close.current() === false) {
stack.push(id)
push()
}
}
window.addEventListener('popstate', onPop)
return () => {
window.removeEventListener('popstate', onPop)
// Geschlossen wurde über einen Knopf oder die Escape-Taste: Unser
// Eintrag steht noch im Verlauf und muss weg, sonst bräuchte die
// Zurück-Geste danach einen Schritt ins Leere.
if (stack.at(-1) === id) {
stack = stack.filter(x => x !== id)
eigeneSchritte++
// Zurück-Geste danach einen Schritt ins Leere. Lagen andere Ansichten
// darüber, ist unser Eintrag nicht mehr der oberste – dann räumt deren
// Aufräumer auf, und wir gehen nicht zurück.
const warOben = stack.at(-1)?.id === id
stack = stack.filter(x => x.id !== id)
if (!warOben) return
// **Erst im nächsten Durchlauf entscheiden.** Öffnet in derselben Runde
// eine andere Ansicht, hat die bis dahin ihren Eintrag angelegt und
// liegt obenauf – dann gehört der Verlauf ihr, und wir rühren ihn nicht
// an. Unser Eintrag bleibt begraben; das kostet einmal einen
// Zurück-Schritt, der nichts sichtbar tut, und ist der ruhigere Fehler.
queueMicrotask(() => {
if (window.history.state?.[stateKey] !== id) return
unterwegs = true
window.history.back()
} else {
stack = stack.filter(x => x !== id)
}
})
}
}, [active])
}
+22
View File
@@ -133,3 +133,25 @@ body {
@media (min-width: 720px) {
.aether-scrim { display: none; }
}
/* ── Meldungen ───────────────────────────────────────────────────────────────
Die erste Bewegung im Projekt, deshalb hier gleich die Regel dazu: Jede
Animation braucht ihre Ausnahme für Menschen, die keine wollen. Das ist
keine Kür – für manche löst Bewegung Übelkeit oder Schwindel aus, und das
Betriebssystem fragt sie genau danach.
Von unten und leicht kleiner: Die Meldung kommt aus der Richtung, in der
sie erscheint, statt aus dem Nichts. 140 ms sind schnell genug, um nicht
zu warten, und lang genug, um nicht zu blinken. */
@keyframes aether-toast-auf {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.aether-toast {
animation: aether-toast-auf 140ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.aether-toast { animation: none; }
}
+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) {