mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-29 06:32:52 +00:00
The number next to the Files title in Manage Files stayed at the account's total while a search narrowed the list. While searching it now shows how many files are listed for the search, as before the redesign, and the total comes back once the search is cleared. Fixes #32815
511 lines
18 KiB
Svelte
511 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { toast } from 'svelte-sonner';
|
|
import { getContext, onDestroy } from 'svelte';
|
|
import type { Writable } from 'svelte/store';
|
|
import dayjs from 'dayjs';
|
|
|
|
import {
|
|
searchUserFiles,
|
|
deleteFileById,
|
|
getUserFileCount,
|
|
getFileById,
|
|
getFileBlobById
|
|
} from '$lib/apis/files';
|
|
import { formatFileSize } from '$lib/utils';
|
|
import Modal from '$lib/components/common/Modal.svelte';
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import Loader from '$lib/components/common/Loader.svelte';
|
|
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
|
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
|
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
import FileItemModal from '$lib/components/common/FileItemModal.svelte';
|
|
import FileTypeIcon from '$lib/components/chat/FileNav/FileTypeIcon.svelte';
|
|
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
|
|
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
|
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
|
import XMark from '$lib/components/icons/XMark.svelte';
|
|
|
|
const i18n: Writable<any> = getContext('i18n');
|
|
const PAGE_SIZE = 50;
|
|
export let show = false;
|
|
|
|
let files: any[] | null = null;
|
|
let fileCount: number | null = null;
|
|
let query = '';
|
|
let orderBy = 'created_at';
|
|
let direction = 'desc';
|
|
let page = 0;
|
|
let allFilesLoaded = false;
|
|
let filesLoading = false;
|
|
let loadError = false;
|
|
let searchTimer: ReturnType<typeof setTimeout>;
|
|
let generation = 0;
|
|
|
|
let selectedIds = new Set<string>();
|
|
let selectionStatus: 'idle' | 'loading' | 'all' | 'error' = 'idle';
|
|
let deleteIds: string[] = [];
|
|
let showDeleteConfirmDialog = false;
|
|
let operation: 'delete' | 'download' | null = null;
|
|
let progress = { done: 0, total: 0, failed: 0 };
|
|
let stopRequested = false;
|
|
let selectedFile: any = null;
|
|
let showFileItemModal = false;
|
|
|
|
$: busy = selectionStatus === 'loading' || !!operation || showDeleteConfirmDialog;
|
|
$: loadedSelected = (files ?? []).filter((file) => selectedIds.has(file.id)).length;
|
|
$: allLoadedSelected = !!files?.length && loadedSelected === files.length;
|
|
$: shownFileCount = query ? (files?.length ?? null) : fileCount;
|
|
$: sortedFiles = [...(files ?? [])].sort((a, b) => {
|
|
const result =
|
|
orderBy === 'filename'
|
|
? (a.filename ?? '').localeCompare(b.filename ?? '')
|
|
: (a.created_at ?? 0) - (b.created_at ?? 0);
|
|
return direction === 'asc' ? result : -result;
|
|
});
|
|
|
|
const loadFiles = async (more = false) => {
|
|
if (!show || (more && (busy || filesLoading || allFilesLoaded || loadError))) return;
|
|
const request = generation;
|
|
const nextPage = more ? page + 1 : 0;
|
|
filesLoading = true;
|
|
loadError = false;
|
|
try {
|
|
const result = await searchUserFiles(
|
|
localStorage.token,
|
|
query ? `*${query}*` : '*',
|
|
nextPage * PAGE_SIZE,
|
|
PAGE_SIZE
|
|
);
|
|
if (request !== generation) return;
|
|
files = more ? [...(files ?? []), ...result] : result;
|
|
page = nextPage;
|
|
allFilesLoaded = result.length < PAGE_SIZE;
|
|
if (!more && !query) {
|
|
const count = await getUserFileCount(localStorage.token);
|
|
if (request === generation) fileCount = count;
|
|
}
|
|
} catch {
|
|
if (request !== generation) return;
|
|
files ??= [];
|
|
loadError = true;
|
|
} finally {
|
|
if (request === generation) filesLoading = false;
|
|
}
|
|
};
|
|
|
|
const resetSearch = (visible: boolean, _query: string) => {
|
|
++generation;
|
|
selectedIds = new Set();
|
|
selectionStatus = 'idle';
|
|
files = null;
|
|
fileCount = null;
|
|
filesLoading = false;
|
|
clearTimeout(searchTimer);
|
|
if (visible) searchTimer = setTimeout(() => loadFiles(), 300);
|
|
};
|
|
$: resetSearch(show, query);
|
|
|
|
const toggleSelection = (ids: string[], selected: boolean) => {
|
|
if (busy) return;
|
|
for (const id of ids) {
|
|
if (selected) selectedIds.add(id);
|
|
else selectedIds.delete(id);
|
|
}
|
|
selectedIds = new Set(selectedIds);
|
|
selectionStatus = 'idle';
|
|
};
|
|
|
|
const selectAllMatching = async () => {
|
|
if (busy) return;
|
|
const request = generation;
|
|
const pattern = query ? `*${query}*` : '*';
|
|
const ids = new Set<string>();
|
|
selectionStatus = 'loading';
|
|
try {
|
|
for (let skip = 0; ; skip += PAGE_SIZE) {
|
|
const result = await searchUserFiles(localStorage.token, pattern, skip, PAGE_SIZE);
|
|
if (request !== generation) return;
|
|
for (const file of result) ids.add(file.id);
|
|
if (result.length < PAGE_SIZE) break;
|
|
}
|
|
selectedIds = ids;
|
|
selectionStatus = 'all';
|
|
} catch {
|
|
if (request === generation) selectionStatus = 'error';
|
|
}
|
|
};
|
|
|
|
const requestDelete = (ids: string[]) => {
|
|
if (busy || !ids.length) return;
|
|
deleteIds = ids;
|
|
showDeleteConfirmDialog = true;
|
|
};
|
|
|
|
const deleteHandler = async () => {
|
|
if (operation || !deleteIds.length) return;
|
|
operation = 'delete';
|
|
stopRequested = false;
|
|
progress = { done: 0, total: deleteIds.length, failed: 0 };
|
|
++generation;
|
|
clearTimeout(searchTimer);
|
|
for (let offset = 0; offset < deleteIds.length && !stopRequested; offset += 3) {
|
|
await Promise.all(
|
|
deleteIds.slice(offset, offset + 3).map(async (id) => {
|
|
try {
|
|
await deleteFileById(localStorage.token, id);
|
|
// toast.success($i18n.t('File deleted successfully.'));
|
|
selectedIds.delete(id);
|
|
files = files?.filter((file) => file.id !== id) ?? null;
|
|
} catch {
|
|
progress.failed++;
|
|
selectedIds.add(id);
|
|
} finally {
|
|
progress.done++;
|
|
selectedIds = new Set(selectedIds);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
const message = $i18n.t(
|
|
'{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.',
|
|
{
|
|
deleted: progress.done - progress.failed,
|
|
failed: progress.failed,
|
|
remaining: progress.total - progress.done
|
|
}
|
|
);
|
|
if (progress.failed) toast.error(message);
|
|
else toast.success(message);
|
|
selectionStatus = 'idle';
|
|
await loadFiles();
|
|
operation = null;
|
|
};
|
|
|
|
const downloadHandler = async (ids: string[]) => {
|
|
if (busy || !ids.length) return;
|
|
operation = 'download';
|
|
stopRequested = false;
|
|
progress = { done: 0, total: ids.length, failed: 0 };
|
|
try {
|
|
// One archive avoids browser prompts for hundreds of separate downloads.
|
|
const zip = ids.length > 1 ? new (await import('jszip')).default() : null;
|
|
let output: Blob | undefined;
|
|
let filename = '';
|
|
for (const id of ids) {
|
|
if (stopRequested) return;
|
|
const file =
|
|
files?.find((file) => file.id === id) ?? (await getFileById(localStorage.token, id));
|
|
if (!file) throw new Error($i18n.t('Could not load files.'));
|
|
const blob = await getFileBlobById(localStorage.token, id, { attachment: true });
|
|
filename = (file.filename || 'file').replace(/[\\/]/g, '_');
|
|
if (filename === '.' || filename === '..') filename = 'file';
|
|
if (zip) {
|
|
let name = filename;
|
|
let suffix = 1;
|
|
while (zip.file(name)) name = `${suffix++}-${filename}`;
|
|
zip.file(name, await blob.arrayBuffer());
|
|
} else output = blob;
|
|
progress.done++;
|
|
}
|
|
if (stopRequested) return;
|
|
if (zip) output = await zip.generateAsync({ type: 'blob' });
|
|
if (stopRequested || !output) return;
|
|
const url = URL.createObjectURL(output);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = zip ? 'files.zip' : filename;
|
|
link.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
operation = null;
|
|
}
|
|
};
|
|
|
|
onDestroy(() => {
|
|
show = false;
|
|
stopRequested = true;
|
|
++generation;
|
|
clearTimeout(searchTimer);
|
|
});
|
|
</script>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteConfirmDialog}
|
|
title={$i18n.t('Delete {{count}} files?', { count: deleteIds.length })}
|
|
message={$i18n.t('This cannot be undone. References to these files may stop working.')}
|
|
confirmLabel={$i18n.t('Delete')}
|
|
on:confirm={deleteHandler}
|
|
/>
|
|
<FileItemModal bind:show={showFileItemModal} item={selectedFile} edit={false} />
|
|
|
|
<Modal
|
|
size="lg"
|
|
bind:show
|
|
onRequestClose={() => {
|
|
if (!operation) show = false;
|
|
}}
|
|
ariaLabel={$i18n.t('Files')}
|
|
>
|
|
<div class="px-4 pb-3 pt-3 text-gray-700 dark:text-gray-200">
|
|
<div class="mb-2 flex h-6 items-center gap-2">
|
|
<div class="flex flex-1 items-center gap-1.5 text-sm font-medium">
|
|
{$i18n.t('Files')}
|
|
{#if shownFileCount !== null}<span class="text-xs font-normal tabular-nums text-gray-400"
|
|
>{shownFileCount}</span
|
|
>{/if}
|
|
</div>
|
|
<Dropdown closeOnSelect align="end">
|
|
<button
|
|
type="button"
|
|
aria-label={$i18n.t('Selection actions')}
|
|
disabled={busy || !selectedIds.size}
|
|
class="flex h-6 items-center gap-1 rounded-lg px-1.5 text-xs text-gray-500 transition hover:text-gray-900 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-100"
|
|
>
|
|
{$i18n.t('Actions')}<Icon name="chevron-down" size={11} />
|
|
</button>
|
|
<div slot="content">
|
|
<DropdownMenu className="min-w-36" style="border: 0">
|
|
<button
|
|
type="button"
|
|
disabled={busy || !selectedIds.size}
|
|
on:click={() => downloadHandler([...selectedIds])}
|
|
><ArrowDownTray className="size-3.5" />{$i18n.t('Download')}</button
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={busy || !selectedIds.size}
|
|
on:click={() => requestDelete([...selectedIds])}
|
|
><GarbageBin className="size-3.5" />{$i18n.t('Delete')}</button
|
|
>
|
|
</DropdownMenu>
|
|
</div>
|
|
</Dropdown>
|
|
<button
|
|
type="button"
|
|
aria-label={$i18n.t('Close')}
|
|
disabled={!!operation}
|
|
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 disabled:opacity-40 dark:hover:text-gray-200"
|
|
on:click={() => {
|
|
show = false;
|
|
}}><XMark className="size-3.5" /></button
|
|
>
|
|
</div>
|
|
|
|
<div class="mb-1 flex h-7 items-center gap-2 px-1 pb-1">
|
|
{#if operation}
|
|
<Spinner className="size-3" />
|
|
<span class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-gray-400" role="status"
|
|
>{$i18n.t(
|
|
operation === 'download'
|
|
? 'Preparing {{processed}} of {{total}}'
|
|
: 'Processed {{processed}} of {{total}}',
|
|
{ processed: progress.done, total: progress.total }
|
|
)}</span
|
|
>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-gray-500 hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
|
|
disabled={stopRequested}
|
|
on:click={() => {
|
|
stopRequested = true;
|
|
}}>{stopRequested ? $i18n.t('Stopping...') : $i18n.t('Stop')}</button
|
|
>
|
|
{:else}
|
|
<Icon name="search" size={13} class="shrink-0 text-gray-400" />
|
|
<input
|
|
class="min-w-0 flex-1 bg-transparent py-1 text-xs outline-hidden placeholder:text-gray-400"
|
|
bind:value={query}
|
|
disabled={showDeleteConfirmDialog}
|
|
placeholder={$i18n.t('Search Files')}
|
|
aria-label={$i18n.t('Search Files')}
|
|
maxlength="500"
|
|
/>
|
|
{#if query}<button
|
|
type="button"
|
|
aria-label={$i18n.t('Clear search')}
|
|
disabled={showDeleteConfirmDialog}
|
|
class="flex size-5 shrink-0 items-center justify-center text-gray-400 hover:text-gray-600"
|
|
on:click={() => {
|
|
query = '';
|
|
}}><XMark className="size-3" /></button
|
|
>{/if}
|
|
{#if selectedIds.size}
|
|
<span
|
|
role="status"
|
|
class="shrink-0 text-[0.6875rem] tabular-nums text-gray-500 dark:text-gray-400"
|
|
>{$i18n.t('{{count}} selected', { count: selectedIds.size })}</span
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={$i18n.t('Clear selection')}
|
|
title={$i18n.t('Clear selection')}
|
|
disabled={busy}
|
|
class="flex size-5 shrink-0 items-center justify-center text-gray-400 hover:text-gray-600 disabled:opacity-40 dark:hover:text-gray-200"
|
|
on:click={() => {
|
|
selectedIds = new Set();
|
|
selectionStatus = 'idle';
|
|
}}><XMark className="size-3" /></button
|
|
>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selectionStatus === 'loading'}
|
|
<div class="flex h-6 items-center gap-1.5 px-1 text-[0.6875rem] text-gray-500" role="status">
|
|
<Spinner className="size-3" /><span>{$i18n.t('Selecting all matching files...')}</span>
|
|
</div>
|
|
{:else if selectionStatus === 'error'}
|
|
<div class="flex items-center gap-2 px-1 py-1 text-[0.6875rem] text-gray-500" role="alert">
|
|
<span
|
|
>{$i18n.t('Could not select all matching files. Your selection has not changed.')}</span
|
|
><button disabled={busy} class="shrink-0 underline" on:click={selectAllMatching}
|
|
>{$i18n.t('Retry')}</button
|
|
>
|
|
</div>
|
|
{:else if allLoadedSelected && !allFilesLoaded && selectionStatus !== 'all' && !operation}
|
|
<button
|
|
class="h-6 px-1 text-[0.6875rem] text-gray-500 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
|
disabled={busy}
|
|
on:click={selectAllMatching}>{$i18n.t('Select all matching files')}</button
|
|
>
|
|
{/if}
|
|
|
|
{#if files !== null}
|
|
{#if files.length}
|
|
<div
|
|
class="grid h-7 grid-cols-[1.75rem_minmax(0,1fr)_3.5rem_1.75rem] items-center gap-x-2 text-[0.6875rem] text-gray-400 sm:grid-cols-[1.75rem_minmax(0,1fr)_4rem_7rem_1.75rem]"
|
|
>
|
|
<div class="flex justify-center">
|
|
<Checkbox
|
|
state={allLoadedSelected ? 'checked' : 'unchecked'}
|
|
indeterminate={loadedSelected > 0 && !allLoadedSelected}
|
|
disabled={busy}
|
|
ariaLabel={$i18n.t('Select loaded files')}
|
|
on:change={() =>
|
|
toggleSelection(
|
|
(files ?? []).map((file) => file.id),
|
|
!allLoadedSelected
|
|
)}
|
|
/>
|
|
</div>
|
|
<button
|
|
disabled={busy}
|
|
class="flex items-center gap-1 text-left hover:text-gray-700 dark:hover:text-gray-200"
|
|
on:click={() => {
|
|
direction = orderBy === 'filename' && direction === 'asc' ? 'desc' : 'asc';
|
|
orderBy = 'filename';
|
|
}}
|
|
>
|
|
{$i18n.t('Filename')}{#if orderBy === 'filename'}<Icon
|
|
name={direction === 'asc' ? 'chevron-up' : 'chevron-down'}
|
|
size={10}
|
|
/>{/if}
|
|
</button>
|
|
<span class="text-right">{$i18n.t('Size')}</span>
|
|
<button
|
|
disabled={busy}
|
|
class="hidden items-center justify-end gap-1 hover:text-gray-700 dark:hover:text-gray-200 sm:flex"
|
|
on:click={() => {
|
|
direction = orderBy === 'created_at' && direction === 'asc' ? 'desc' : 'asc';
|
|
orderBy = 'created_at';
|
|
}}
|
|
>
|
|
{$i18n.t('Created at')}{#if orderBy === 'created_at'}<Icon
|
|
name={direction === 'asc' ? 'chevron-up' : 'chevron-down'}
|
|
size={10}
|
|
/>{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<div class="max-h-[min(28rem,calc(100dvh-12rem))] overflow-y-auto">
|
|
{#if !files.length && !loadError}<div
|
|
class="flex min-h-20 items-center justify-center text-xs text-gray-400"
|
|
>
|
|
{$i18n.t('No files found')}
|
|
</div>{/if}
|
|
{#each sortedFiles as file (file.id)}
|
|
<div
|
|
class="group grid h-7 grid-cols-[1.75rem_minmax(0,1fr)_3.5rem_1.75rem] items-center gap-x-2 rounded-xl text-xs transition-colors hover:bg-gray-50/40 dark:hover:bg-white/4 sm:grid-cols-[1.75rem_minmax(0,1fr)_4rem_7rem_1.75rem]"
|
|
>
|
|
<div class="flex justify-center">
|
|
<Checkbox
|
|
state={selectedIds.has(file.id) ? 'checked' : 'unchecked'}
|
|
disabled={busy}
|
|
ariaLabel={$i18n.t('Select {{filename}}', { filename: file.filename })}
|
|
on:change={() => toggleSelection([file.id], !selectedIds.has(file.id))}
|
|
/>
|
|
</div>
|
|
<button
|
|
class="flex h-full min-w-0 items-center gap-1.5 text-left"
|
|
title={file.filename}
|
|
disabled={!!operation}
|
|
on:click={() => {
|
|
selectedFile = {
|
|
id: file.id,
|
|
name: file.filename,
|
|
type: 'file',
|
|
size: file.meta?.size,
|
|
meta: file.meta
|
|
};
|
|
showFileItemModal = true;
|
|
}}
|
|
>
|
|
<FileTypeIcon name={file.filename} size={14} /><span class="truncate"
|
|
>{file.filename}</span
|
|
>
|
|
</button>
|
|
<span class="truncate text-right text-[0.6875rem] tabular-nums text-gray-400"
|
|
>{formatFileSize(file.meta?.size ?? 0)}</span
|
|
>
|
|
<span
|
|
class="hidden text-right text-[0.6875rem] tabular-nums text-gray-400 sm:block"
|
|
title={dayjs(file.created_at * 1000)
|
|
.locale($i18n.language)
|
|
.format('MMM D, YYYY h:mm A')}
|
|
>{dayjs(file.created_at * 1000)
|
|
.locale($i18n.language)
|
|
.format('MMM D, YYYY')}</span
|
|
>
|
|
<Dropdown closeOnSelect align="end">
|
|
<button
|
|
type="button"
|
|
aria-label={$i18n.t('Actions for {{filename}}', { filename: file.filename })}
|
|
disabled={busy}
|
|
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 disabled:opacity-40 dark:hover:text-gray-200"
|
|
><Icon name="three-dots" size={14} /></button
|
|
>
|
|
<div slot="content">
|
|
<DropdownMenu className="min-w-36" style="border: 0">
|
|
<button type="button" disabled={busy} on:click={() => downloadHandler([file.id])}
|
|
><ArrowDownTray className="size-3.5" />{$i18n.t('Download')}</button
|
|
>
|
|
<!-- {$i18n.t('Delete File')} -->
|
|
<button type="button" disabled={busy} on:click={() => requestDelete([file.id])}
|
|
><GarbageBin className="size-3.5" />{$i18n.t('Delete')}</button
|
|
>
|
|
</DropdownMenu>
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
{/each}
|
|
{#if loadError}
|
|
<div role="alert" class="py-2 text-center text-xs text-gray-500">
|
|
{$i18n.t('Could not load files.')}
|
|
<button disabled={busy} class="underline" on:click={() => loadFiles()}
|
|
>{$i18n.t('Retry')}</button
|
|
>
|
|
</div>
|
|
{:else if !allFilesLoaded && !busy}
|
|
<Loader on:visible={() => loadFiles(true)}
|
|
><div class="flex justify-center py-2"><Spinner className="size-3" /></div></Loader
|
|
>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="flex min-h-20 items-center justify-center"><Spinner className="size-4" /></div>
|
|
{/if}
|
|
</div>
|
|
</Modal>
|