perf(files): stale-while-revalidate folder listings with conditional ETag
Every folder navigation re-downloaded the full listing: `listFolder` cache-busted
with `?t=<ts>` + `Cache-Control: no-store`, so back/forward and re-entering a
folder always paid a full round-trip + payload.
Now the files browser caches listings in memory and serves SWR:
- On navigation it paints a previously-visited folder instantly from cache, then
revalidates with `If-None-Match` (the backend ETag covers folders + files +
favorite/share badges, so it's a faithful validator). Unchanged → 304 with an
empty body; changed → 200 refreshes cache + UI.
- A generation token guards against a slow in-flight response clobbering a newer
navigation; breadcrumbs now resolve independently so they never block the grid
paint.
- Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go
through `reload()`, which drops the cache and refetches fresh — no stale view
after an action.
API layer (`folders.ts`):
- `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch
(200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a
non-conditional wrapper for the move-dialog tree.
- A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` /
`invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out
of the way; revalidation is driven entirely by our own ETag.
Net: instant back/forward navigation, and an unchanged folder revalidates with a
0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit
tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation),
npm run check, and a headless render of the real files route (list + grid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||||
|
|
||||||
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import {
|
||||||
|
fetchFolderListing,
|
||||||
|
getCachedFolder,
|
||||||
|
cacheFolder,
|
||||||
|
invalidateFolderCache,
|
||||||
|
type FolderListing
|
||||||
|
} from './folders';
|
||||||
|
|
||||||
|
type RawListing = {
|
||||||
|
folders?: unknown[];
|
||||||
|
files?: unknown[];
|
||||||
|
favorite_ids?: string[];
|
||||||
|
shared_ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response {
|
||||||
|
return {
|
||||||
|
status: opts.status,
|
||||||
|
ok: opts.status >= 200 && opts.status < 300,
|
||||||
|
json: async () => opts.body ?? {},
|
||||||
|
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) }
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyListing = (): FolderListing => ({
|
||||||
|
folders: [],
|
||||||
|
files: [],
|
||||||
|
favoriteIds: [],
|
||||||
|
sharedIds: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const initHeaders = (call: number): Record<string, string> =>
|
||||||
|
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
invalidateFolderCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchFolderListing (conditional)', () => {
|
||||||
|
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => {
|
||||||
|
vi.mocked(apiFetch).mockResolvedValue(
|
||||||
|
fakeRes({
|
||||||
|
status: 200,
|
||||||
|
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] },
|
||||||
|
etag: '"v1"'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const r = await fetchFolderListing('f1');
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.etag).toBe('"v1"');
|
||||||
|
expect(r.listing?.favoriteIds).toEqual(['a']);
|
||||||
|
expect(r.listing?.sharedIds).toEqual(['b']);
|
||||||
|
expect(initHeaders(0)['If-None-Match']).toBeUndefined();
|
||||||
|
// No cache-busting query param — the URL must be stable for revalidation.
|
||||||
|
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends If-None-Match and surfaces a 304 with no body', async () => {
|
||||||
|
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 }));
|
||||||
|
const r = await fetchFolderListing('f1', { etag: '"v1"' });
|
||||||
|
expect(r.status).toBe(304);
|
||||||
|
expect(r.listing).toBeUndefined();
|
||||||
|
expect(initHeaders(0)['If-None-Match']).toBe('"v1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a 403 carrying its status', async () => {
|
||||||
|
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 403 }));
|
||||||
|
await expect(fetchFolderListing('f1')).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('folder listing cache (LRU + invalidation)', () => {
|
||||||
|
it('stores and retrieves a listing + its ETag', () => {
|
||||||
|
cacheFolder('a', emptyListing(), '"1"');
|
||||||
|
expect(getCachedFolder('a')?.etag).toBe('"1"');
|
||||||
|
expect(getCachedFolder('missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the least-recently-used entry past the cap', () => {
|
||||||
|
for (let i = 0; i < 45; i++) cacheFolder(`f${i}`, emptyListing());
|
||||||
|
expect(getCachedFolder('f0')).toBeUndefined(); // evicted (cap is 40)
|
||||||
|
expect(getCachedFolder('f44')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a read bumps recency so the touched entry survives eviction', () => {
|
||||||
|
for (let i = 0; i < 40; i++) cacheFolder(`f${i}`, emptyListing());
|
||||||
|
getCachedFolder('f0'); // bump f0 to most-recent
|
||||||
|
cacheFolder('extra', emptyListing()); // forces one eviction
|
||||||
|
expect(getCachedFolder('f0')).toBeDefined();
|
||||||
|
expect(getCachedFolder('f1')).toBeUndefined(); // f1 was now the oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates a single folder, or the whole cache', () => {
|
||||||
|
cacheFolder('a', emptyListing());
|
||||||
|
cacheFolder('b', emptyListing());
|
||||||
|
invalidateFolderCache('a');
|
||||||
|
expect(getCachedFolder('a')).toBeUndefined();
|
||||||
|
expect(getCachedFolder('b')).toBeDefined();
|
||||||
|
invalidateFolderCache();
|
||||||
|
expect(getCachedFolder('b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,66 @@ export interface FolderListing {
|
|||||||
sharedIds: string[];
|
sharedIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a (possibly conditional) listing fetch. */
|
||||||
|
export interface FolderListingResult {
|
||||||
|
/** 200 with a fresh `listing`, or 304 → the caller should keep its cache. */
|
||||||
|
status: number;
|
||||||
|
listing?: FolderListing;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-memory listing cache (stale-while-revalidate) ─────────────────────────
|
||||||
|
// Lets the files view paint a previously-visited folder instantly on
|
||||||
|
// back/forward navigation, then revalidate with `If-None-Match` (304 = no body).
|
||||||
|
interface CachedFolder {
|
||||||
|
listing: FolderListing;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
const FOLDER_CACHE_MAX = 40;
|
||||||
|
const folderCache = new Map<string, CachedFolder>();
|
||||||
|
|
||||||
|
/** Cached listing for a folder, bumped to most-recently-used. */
|
||||||
|
export function getCachedFolder(folderId: string): CachedFolder | undefined {
|
||||||
|
const hit = folderCache.get(folderId);
|
||||||
|
if (hit) {
|
||||||
|
folderCache.delete(folderId);
|
||||||
|
folderCache.set(folderId, hit);
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheFolder(folderId: string, listing: FolderListing, etag?: string): void {
|
||||||
|
folderCache.delete(folderId);
|
||||||
|
folderCache.set(folderId, { listing, etag });
|
||||||
|
// Evict the least-recently-used entries past the cap.
|
||||||
|
while (folderCache.size > FOLDER_CACHE_MAX) {
|
||||||
|
const oldest = folderCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
folderCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop one folder, or the whole cache (no id), after a mutation. */
|
||||||
|
export function invalidateFolderCache(folderId?: string): void {
|
||||||
|
if (folderId === undefined) folderCache.clear();
|
||||||
|
else folderCache.delete(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListing(raw: unknown): FolderListing {
|
||||||
|
const o = (raw ?? {}) as {
|
||||||
|
folders?: FolderItem[];
|
||||||
|
files?: FileItem[];
|
||||||
|
favorite_ids?: string[];
|
||||||
|
shared_ids?: string[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
folders: Array.isArray(o.folders) ? o.folders : [],
|
||||||
|
files: Array.isArray(o.files) ? o.files : [],
|
||||||
|
favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [],
|
||||||
|
sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Top-level folders for the user; the first entry is the home folder. */
|
/** Top-level folders for the user; the first entry is the home folder. */
|
||||||
export function listRootFolders(): Promise<FolderItem[]> {
|
export function listRootFolders(): Promise<FolderItem[]> {
|
||||||
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
||||||
@@ -28,33 +88,41 @@ export function getFolder(id: string): Promise<FolderItem> {
|
|||||||
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
|
/**
|
||||||
const ts = Math.floor(Date.now() / 1000);
|
* Fetch a folder listing, optionally conditionally. With `etag` set it sends
|
||||||
let url = `/api/folders/${folderId}/listing?t=${ts}`;
|
* `If-None-Match`; the server replies 304 (empty body) when nothing changed —
|
||||||
const headers: Record<string, string> = {
|
* the ETag covers folders + files + favorite/share badges — so the caller can
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of
|
||||||
};
|
* the way; revalidation is driven entirely by our own ETag.
|
||||||
if (forceRefresh) {
|
*/
|
||||||
url += '&force_refresh=true';
|
export async function fetchFolderListing(
|
||||||
|
folderId: string,
|
||||||
|
opts: { etag?: string; forceRefresh?: boolean } = {}
|
||||||
|
): Promise<FolderListingResult> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (opts.etag) headers['If-None-Match'] = opts.etag;
|
||||||
|
let url = `/api/folders/${folderId}/listing`;
|
||||||
|
if (opts.forceRefresh) {
|
||||||
|
url += '?force_refresh=true';
|
||||||
headers['X-Force-Refresh'] = 'true';
|
headers['X-Force-Refresh'] = 'true';
|
||||||
}
|
}
|
||||||
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
|
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
|
||||||
|
if (res.status === 304) return { status: 304 };
|
||||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||||
const listing = (await res.json()) as {
|
|
||||||
folders?: FolderItem[];
|
|
||||||
files?: FileItem[];
|
|
||||||
favorite_ids?: string[];
|
|
||||||
shared_ids?: string[];
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
folders: Array.isArray(listing.folders) ? listing.folders : [],
|
status: 200,
|
||||||
files: Array.isArray(listing.files) ? listing.files : [],
|
listing: parseListing(await res.json()),
|
||||||
favoriteIds: Array.isArray(listing.favorite_ids) ? listing.favorite_ids : [],
|
etag: res.headers.get('ETag') ?? undefined
|
||||||
sharedIds: Array.isArray(listing.shared_ids) ? listing.shared_ids : []
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
|
||||||
|
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
|
||||||
|
const res = await fetchFolderListing(folderId, { forceRefresh });
|
||||||
|
return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
|
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
|
||||||
const res = await apiFetch('/api/folders', {
|
const res = await apiFetch('/api/folders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import {
|
import {
|
||||||
|
cacheFolder,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
|
fetchFolderListing,
|
||||||
|
getCachedFolder,
|
||||||
getFolder,
|
getFolder,
|
||||||
listFolder,
|
invalidateFolderCache,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
type FolderListing
|
type FolderListing
|
||||||
@@ -133,15 +136,20 @@
|
|||||||
return metas;
|
return metas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bumped on every load; a stale in-flight response checks this before it
|
||||||
|
// writes state, so a fast navigation can't be clobbered by an older fetch.
|
||||||
|
let loadSeq = 0;
|
||||||
|
|
||||||
|
function applyListing(data: FolderListing) {
|
||||||
|
listing = data;
|
||||||
|
favoriteIds = new Set(data.favoriteIds);
|
||||||
|
sharedIds = new Set(data.sharedIds);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
|
||||||
error = null;
|
error = null;
|
||||||
// Arm the delayed skeleton; cancel it the moment the load settles so fast
|
const seq = ++loadSeq;
|
||||||
// loads never flash placeholders (mirrors filesView.js' 100ms timer).
|
|
||||||
const skeletonTimer = setTimeout(() => {
|
|
||||||
if (loading) showSkeleton = true;
|
|
||||||
}, 100);
|
|
||||||
try {
|
|
||||||
// External users have no home folder; send them to shared-with-me.
|
// External users have no home folder; send them to shared-with-me.
|
||||||
if (session.isExternalUser && pathSegments.length === 0) {
|
if (session.isExternalUser && pathSegments.length === 0) {
|
||||||
await goto('/shared-with-me', { replaceState: true });
|
await goto('/shared-with-me', { replaceState: true });
|
||||||
@@ -155,14 +163,41 @@
|
|||||||
}
|
}
|
||||||
currentId = folderId;
|
currentId = folderId;
|
||||||
filesStore.currentFolder = folderId;
|
filesStore.currentFolder = folderId;
|
||||||
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
|
|
||||||
listing = data;
|
// Stale-while-revalidate: paint a previously-visited folder instantly,
|
||||||
crumbs = trail;
|
// then revalidate with If-None-Match (304 = keep what's shown).
|
||||||
favoriteIds = new Set(data.favoriteIds);
|
const cached = getCachedFolder(folderId);
|
||||||
sharedIds = new Set(data.sharedIds);
|
if (cached) {
|
||||||
|
applyListing(cached.listing);
|
||||||
|
loading = false;
|
||||||
|
showSkeleton = false;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
// Delayed skeleton, only when there's nothing cached to show yet.
|
||||||
|
const skeletonTimer = setTimeout(() => {
|
||||||
|
if (loading) showSkeleton = true;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Breadcrumbs resolve independently so they never block the grid paint.
|
||||||
|
void buildCrumbs(pathSegments).then((trail) => {
|
||||||
|
if (seq === loadSeq) crumbs = trail;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchFolderListing(folderId, { etag: cached?.etag });
|
||||||
|
if (seq !== loadSeq) return; // superseded by a newer navigation
|
||||||
|
if (res.status === 200 && res.listing) {
|
||||||
|
applyListing(res.listing);
|
||||||
|
cacheFolder(folderId, res.listing, res.etag);
|
||||||
|
}
|
||||||
|
// 304 → the cached copy already on screen is current.
|
||||||
|
error = null;
|
||||||
maybeOpenDeepLink();
|
maybeOpenDeepLink();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 403 → friendly message rather than the raw "Forbidden" error string.
|
if (seq !== loadSeq) return;
|
||||||
|
// With a cached view already shown, keep it on a transient failure.
|
||||||
|
if (!cached) {
|
||||||
const status = (e as { status?: number })?.status;
|
const status = (e as { status?: number })?.status;
|
||||||
error =
|
error =
|
||||||
status === 403
|
status === 403
|
||||||
@@ -170,12 +205,21 @@
|
|||||||
: e instanceof Error
|
: e instanceof Error
|
||||||
? e.message
|
? e.message
|
||||||
: String(e);
|
: String(e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(skeletonTimer);
|
clearTimeout(skeletonTimer);
|
||||||
|
if (seq === loadSeq) {
|
||||||
loading = false;
|
loading = false;
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data changed — drop cached listings and reload the current folder fresh. */
|
||||||
|
async function reload() {
|
||||||
|
invalidateFolderCache();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep-link auto-open: when the URL carries `?file=<id>` and that file is in
|
* Deep-link auto-open: when the URL carries `?file=<id>` and that file is in
|
||||||
@@ -207,7 +251,7 @@
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
await createFolder(name, currentId);
|
await createFolder(name, currentId);
|
||||||
await load();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -253,7 +297,7 @@
|
|||||||
)
|
)
|
||||||
: t('files.uploaded', 'Upload complete');
|
: t('files.uploaded', 'Upload complete');
|
||||||
ui.finishProgress(nid, done, 'success');
|
ui.finishProgress(nid, done, 'success');
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -286,7 +330,7 @@
|
|||||||
try {
|
try {
|
||||||
if (kind === 'file') await renameFile(id, name);
|
if (kind === 'file') await renameFile(id, name);
|
||||||
else await renameFolder(id, name);
|
else await renameFolder(id, name);
|
||||||
await load();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -303,7 +347,7 @@
|
|||||||
try {
|
try {
|
||||||
if (kind === 'file') await deleteFile(id);
|
if (kind === 'file') await deleteFile(id);
|
||||||
else await deleteFolder(id);
|
else await deleteFolder(id);
|
||||||
await load();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -522,7 +566,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await load();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
||||||
@@ -561,7 +605,7 @@
|
|||||||
else await moveFolder(it.id, targetFolderId);
|
else await moveFolder(it.id, targetFolderId);
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorToast(err);
|
errorToast(err);
|
||||||
}
|
}
|
||||||
@@ -738,7 +782,7 @@
|
|||||||
await uploadFile(dirId, file);
|
await uploadFile(dirId, file);
|
||||||
}
|
}
|
||||||
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorToast(err);
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1418,7 +1462,7 @@
|
|||||||
mode={moveMode}
|
mode={moveMode}
|
||||||
onmoved={() => {
|
onmoved={() => {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
void load();
|
void reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ShareDialog
|
<ShareDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user