fix(nextcloud-files): WR-03 hoechstens vier gleichzeitige Nextcloud-Aufrufe je Zugang

- die Aufrufsperre vergibt je Zugangsschluessel vier Plaetze bis zur Antwort (Kopfzeilen);
  weitere Aufrufe warten der Reihe nach und pruefen danach, ob der Zugang inzwischen tot
  oder der Ursprung gesperrt ist; ein widerrufener Zugang erzeugt so hoechstens vier 401
- Hochladen und Zusammenbau (Antwort erst nach dem ganzen Koerper) belegen keinen Platz
- Weboberflaeche: Vorschaubilder laden weiter lazy; schlaegt eines fehl, prueft die Ansicht
  hoechstens alle 10 s die Verbindung und kehrt bei connectionExpired zur Anmeldung zurueck

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:39:05 +02:00
parent ddae9400a3
commit 07474bd162
10 changed files with 273 additions and 6 deletions
@@ -520,3 +520,27 @@ describe('FileBrowser — Ziehen und Ablegen', () => {
}
});
});
describe('FileBrowser — Vorschaubilder (WR-03)', () => {
it('ein fehlgeschlagenes Vorschaubild prueft die Verbindung; ist sie abgelaufen, geht es zum Anmeldebildschirm', async () => {
const photos = [
folder('/Projekte'),
entry('/Foto.jpg', { mime: 'image/jpeg', hasPreview: true, fileId: '501' }),
entry('/Bild.png', { mime: 'image/png', hasPreview: true, fileId: '502' }),
];
mockList.mockImplementation(async (p: string) => listing(p, p === '/' ? photos : []));
const { container } = await mount();
const imgs = container.querySelectorAll('img');
expect(imgs.length).toBe(2);
expect(Array.from(imgs).every((i) => i.getAttribute('loading') === 'lazy')).toBe(true);
const callsBefore = mockList.mock.calls.length;
mockList.mockRejectedValue(
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
);
fireEvent.error(imgs[0]);
fireEvent.error(imgs[1]);
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
// Zwei Fehler kurz hintereinander loesen nur EINE Pruefung aus.
expect(mockList.mock.calls.length - callsBefore).toBe(1);
});
});
@@ -736,6 +736,16 @@ export function FileBrowser({
}
};
// Ein Vorschaubild schlaegt fehl: hoechstens alle 10 s den Ordner still neu laden. Ist die
// Verbindung abgelaufen, fuehrt das zum Anmeldebildschirm, und keine weiteren Bilder laden (WR-03).
const previewCheckAt = useRef(0);
const onPreviewError = useCallback(() => {
const at = Date.now();
if (at - previewCheckAt.current < 10_000) return;
previewCheckAt.current = at;
void load(pathRef.current, { quiet: true });
}, [load]);
// --- Darstellung ------------------------------------------------------------------------------
const viewProps = {
@@ -750,6 +760,7 @@ export function FileBrowser({
onToggle,
onMenu: openMenu,
onFocusItem: setFocusIndex,
onPreviewError,
};
const targetName =
@@ -10,7 +10,7 @@ import { CheckIcon, MoreIcon } from './icons';
import { TypeTile } from './TypeTile';
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
function PreviewSurface({ entry }: { entry: NcEntry }) {
function PreviewSurface({ entry, onError }: { entry: NcEntry; onError?: () => void }) {
const [failed, setFailed] = useState(false);
// Echte Vorschauen fuer Bilder und Videos; Textvorschauen waeren nur ein fast leeres Blatt.
const family = fileTypeOf(entry).family;
@@ -27,7 +27,10 @@ function PreviewSurface({ entry }: { entry: NcEntry }) {
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailed(true)}
onError={() => {
setFailed(true);
onError?.();
}}
/>
) : (
<TypeTile entry={entry} size="lg" />
@@ -51,6 +54,7 @@ export function FileGrid({
onToggle,
onMenu,
onFocusItem,
onPreviewError,
}: EntryViewProps) {
const t = useTranslations('nextcloudFiles.browser');
const locale = useLocale();
@@ -101,7 +105,7 @@ export function FileGrid({
: 'hover:bg-accent/80'
}`}
>
<PreviewSurface entry={entry} />
<PreviewSurface entry={entry} onError={onPreviewError} />
<input
type="checkbox"
tabIndex={-1}
@@ -29,6 +29,11 @@ export interface EntryViewProps {
onToggle: (entry: NcEntry, index: number) => void;
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
onFocusItem: (index: number) => void;
/**
* Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die
* Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck.
*/
onPreviewError?: () => void;
}
/**
@@ -50,6 +55,7 @@ export function FileList({
onToggle,
onMenu,
onFocusItem,
onPreviewError,
sort,
onSortChange,
onSelectAll,
@@ -184,7 +190,7 @@ export function FileList({
</td>
<td className="py-0 pl-1 pr-2">
<span className="flex min-w-0 items-center gap-3">
<TypeTile entry={entry} preview />
<TypeTile entry={entry} preview onPreviewError={onPreviewError} />
{isFolder ? (
<button
type="button"
@@ -40,12 +40,15 @@ export function TypeTile({
entry,
size = 'sm',
preview = false,
onPreviewError,
}: {
entry: Pick<NcEntry, 'name' | 'type' | 'mime'> &
Partial<Pick<NcEntry, 'hasPreview' | 'fileId' | 'etag'>>;
size?: 'sm' | 'lg';
/** Vorschaubild statt Kurzzeichen, wenn die Nextcloud eines liefert. */
preview?: boolean;
/** Das Vorschaubild liess sich nicht laden. */
onPreviewError?: () => void;
}) {
const [failed, setFailed] = useState(false);
const { family, code } = fileTypeOf(entry);
@@ -69,7 +72,10 @@ export function TypeTile({
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailed(true)}
onError={() => {
setFailed(true);
onPreviewError?.();
}}
/>
</span>
);