perf(files-ui): stream media/downloads natively, rAF rubber-band, Map selection

Four related fixes to stop buffering files in page memory and stop
hammering layout from the selection paths:

- Inline viewer media: video/audio fetched the ENTIRE file into a blob
  before the first frame (a 2 GB video = 2 GB of tab heap, no
  progressive playback, no seek). The <video>/<audio> src now points
  straight at the same-origin API URL — cookies travel automatically
  and the browser streams with native Range requests, exactly like the
  music player already did.

- Downloads (file, folder ZIP, batch ZIP, viewer button): the
  fetch → blob → objectURL pattern materialized the whole payload in
  RAM before the save dialog appeared (a 10 GB batch ZIP risked
  crashing the tab) with no download progress UI. New shared
  utils/download.js hands the URL to the browser, which streams to
  disk with its own progress UI. Batch download uses the existing GET
  endpoint (same URL contract as the drag-out DownloadURL, now shared
  via buildBatchDownloadUrl); selections whose id list cannot fit in a
  URL keep the buffered POST fallback. Trade-off: failed downloads now
  surface in the browser's download shelf instead of an in-app toast.

- Rubber-band lasso: every mousemove (>100/s) walked all cards
  interleaving getBoundingClientRect() reads with class writes — up to
  N forced reflows per event, freezing the frame rate on folders with
  thousands of loaded rows. Card rects (+ item info) are now snapshot
  once per drag (rebuilt on scroll), and a single rAF pass per frame
  compares against the cached geometry, touching only cards whose
  selection state changed.

- batchToolbar: getSelection() rebuilt the selection by scanning every
  .file-item in the document (the TODO admitted it); it now reads the
  _selected Map that every selection path already keeps in sync.
  clear() scopes its DOM sweep to #files-list (the only container the
  toolbar manages) instead of the whole document.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:02:13 +00:00
parent 8d040314a3
commit fd80a3de67
5 changed files with 232 additions and 186 deletions
+82 -28
View File
@@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js';
import { inlineViewer } from '../features/files/inlineViewer.js'; import { inlineViewer } from '../features/files/inlineViewer.js';
import { wopiEditor } from '../features/files/wopiEditor.js'; import { wopiEditor } from '../features/files/wopiEditor.js';
import { recent } from '../features/library/recent.js'; import { recent } from '../features/library/recent.js';
import { buildBatchDownloadUrl } from '../utils/download.js';
import { positionMenu } from '../utils/menuPosition.js'; import { positionMenu } from '../utils/menuPosition.js';
import { loadFiles } from './filesView.js'; import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js'; import { updateHistory } from './main.js';
@@ -774,7 +775,7 @@ const ui = {
if (item?.type === 'file') fileIds.push(item.id); if (item?.type === 'file') fileIds.push(item.id);
else if (item) folderIds.push(item.id); else if (item) folderIds.push(item.id);
}); });
downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`; downloadUrl = `${window.location.origin}${buildBatchDownloadUrl(fileIds, folderIds)}`;
} }
e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`);
@@ -1099,6 +1100,69 @@ function initRubberBandSelection() {
let active = false; let active = false;
let startX = 0, let startX = 0,
startY = 0; startY = 0;
let curX = 0,
curY = 0;
let rafId = 0;
/**
* Card geometry snapshot taken once per drag (and rebuilt on scroll).
* Comparing the lasso against these cached rects means the per-frame
* pass performs zero DOM reads — no forced reflow per card.
* @type {Array<{el: HTMLElement, left: number, top: number, right: number,
* bottom: number, info: ReturnType<typeof batchToolbar._extractInfo>,
* selected: boolean}> | null}
*/
let cardCache = null;
const buildCardCache = () => {
cardCache = [];
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const el = /** @type {HTMLElement} */ (card);
const r = el.getBoundingClientRect();
cardCache.push({
el,
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
info: batchToolbar ? batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (el)) : null,
selected: el.classList.contains('selected')
});
});
};
// Scrolling mid-drag shifts every viewport rect — drop the snapshot so
// the next frame rebuilds it.
const invalidateCardCache = () => {
cardCache = null;
};
/** One classification pass per animation frame (cached rects only). */
const classifyCards = () => {
rafId = 0;
if (!cardCache) buildCardCache();
const left = Math.min(startX, curX);
const top = Math.min(startY, curY);
const right = Math.max(startX, curX);
const bottom = Math.max(startY, curY);
for (const entry of cardCache) {
const intersects = entry.left < right && entry.right > left && entry.top < bottom && entry.bottom > top;
if (intersects === entry.selected) continue;
entry.selected = intersects;
entry.el.classList.toggle('selected', intersects);
// Sync with batchToolbar module (only on state change)
if (batchToolbar && entry.info) {
if (intersects) {
batchToolbar.select(entry.info.id, entry.info.name, entry.info.type, entry.info.parentId);
} else {
batchToolbar.deselect(entry.info.id);
}
}
}
};
// We listen on the whole files-container (covers grid + empty space) // We listen on the whole files-container (covers grid + empty space)
const container = document.querySelector('.files-container') || document.getElementById('files-list'); const container = document.querySelector('.files-container') || document.getElementById('files-list');
@@ -1123,6 +1187,10 @@ function initRubberBandSelection() {
active = true; active = true;
startX = e.clientX; startX = e.clientX;
startY = e.clientY; startY = e.clientY;
curX = startX;
curY = startY;
cardCache = null; // built lazily on the first classification frame
document.addEventListener('scroll', invalidateCardCache, { capture: true, passive: true });
selRect.style.left = `${startX}px`; selRect.style.left = `${startX}px`;
selRect.style.top = `${startY}px`; selRect.style.top = `${startY}px`;
@@ -1136,8 +1204,8 @@ function initRubberBandSelection() {
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!active) return; if (!active) return;
const curX = e.clientX; curX = e.clientX;
const curY = e.clientY; curY = e.clientY;
const left = Math.min(startX, curX); const left = Math.min(startX, curX);
const top = Math.min(startY, curY); const top = Math.min(startY, curY);
@@ -1149,41 +1217,27 @@ function initRubberBandSelection() {
selRect.style.display = 'block'; selRect.style.display = 'block';
} }
// Style writes only — no layout reads here. The card highlighting
// runs at most once per frame against the cached geometry.
selRect.style.left = `${left}px`; selRect.style.left = `${left}px`;
selRect.style.top = `${top}px`; selRect.style.top = `${top}px`;
selRect.style.width = `${width}px`; selRect.style.width = `${width}px`;
selRect.style.height = `${height}px`; selRect.style.height = `${height}px`;
// Highlight cards that intersect with the rectangle if (!rafId) rafId = requestAnimationFrame(classifyCards);
const rectBounds = { left, top, right: left + width, bottom: top + height };
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const cardRect = card.getBoundingClientRect();
const intersects =
cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top;
if (intersects) {
card.classList.add('selected');
// Sync with batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId);
}
} else {
card.classList.remove('selected');
// Deselect from batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.deselect(info.id);
}
}
});
}); });
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (!active) return; if (!active) return;
active = false; active = false;
document.removeEventListener('scroll', invalidateCardCache, { capture: true });
// Apply the still-pending classification so the final lasso
// position is what determines the selection.
if (rafId) {
cancelAnimationFrame(rafId);
classifyCards();
}
cardCache = null;
const hadSelection = selRect.style.display === 'block'; const hadSelection = selRect.style.display === 'block';
selRect.style.display = 'none'; selRect.style.display = 'none';
// Update the batch bar after rubber band selection completes // Update the batch bar after rubber band selection completes
+35 -17
View File
@@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js'; import { showConfirmDialog, ui } from '../../app/ui.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js';
import { favorites } from '../library/favorites.js'; import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js'; import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
@@ -126,10 +127,14 @@ const batchToolbar = {
clear() { clear() {
this._selected.clear(); this._selected.clear();
this._lastClickedIndex = -1; this._lastClickedIndex = -1;
document.querySelectorAll('.file-item.selected').forEach((el) => { // Scope the DOM sweep to the files list — the only container this
// toolbar manages (see `selectAll`) — instead of the whole document,
// and only touch checkboxes that are actually checked.
const list = document.getElementById('files-list');
list?.querySelectorAll('.file-item.selected').forEach((el) => {
el.classList.remove('selected'); el.classList.remove('selected');
}); });
document.querySelectorAll('.item-checkbox').forEach((cb) => { list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => {
/** @type {HTMLInputElement} */ (cb).checked = false; /** @type {HTMLInputElement} */ (cb).checked = false;
}); });
// Reset the active component's internal selection state without going // Reset the active component's internal selection state without going
@@ -173,15 +178,16 @@ const batchToolbar = {
/** @type {Array<string>} */ /** @type {Array<string>} */
const folderIds = []; const folderIds = [];
// TODO optimize & check if _selected is a better use // `_selected` is the source of truth (every selection path keeps it
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => { // in sync) — no need to re-derive the selection from a DOM scan.
if (item.dataset.fileId) { for (const sel of this._selected.values()) {
fileIds.push(item.dataset.fileId); if (sel.type === 'file') {
} else { fileIds.push(sel.id);
// ignore selectedItem if this is the target } else if (targtFolderId && targtFolderId !== sel.id) {
if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId); // ignore the selected folder if it is the drop target itself
folderIds.push(sel.id);
} }
}); }
return { return {
fileIds: fileIds, fileIds: fileIds,
@@ -454,10 +460,22 @@ const batchToolbar = {
ui.showNotification('Preparing download', 'Creating ZIP archive...'); ui.showNotification('Preparing download', 'Creating ZIP archive...');
try { const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id);
const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id);
const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); const zipName = `oxicloud-download-${Date.now()}.zip`;
// Browser-native download via the GET variant of the endpoint: the
// ZIP streams to disk instead of being buffered whole in the tab's
// memory (a multi-GB selection used to risk crashing the tab).
const url = buildBatchDownloadUrl(fileIds, folderIds);
if (url.length <= 4000) {
triggerBrowserDownload(url, zipName);
return;
}
// Selections too large for a URL (~100+ items) keep the buffered
// POST path — the id list only fits in a request body.
try {
const response = await fetch('/api/batch/download', { const response = await fetch('/api/batch/download', {
method: 'POST', method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
@@ -467,14 +485,14 @@ const batchToolbar = {
if (!response.ok) throw new Error(`Server returned ${response.status}`); if (!response.ok) throw new Error(`Server returned ${response.status}`);
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = blobUrl;
link.download = `oxicloud-download-${Date.now()}.zip`; link.download = zipName;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(blobUrl);
} catch (e) { } catch (e) {
console.error('Batch download error:', e); console.error('Batch download error:', e);
ui.showNotification('Error', 'Could not download selected items'); ui.showNotification('Error', 'Could not download selected items');
+8 -46
View File
@@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
import { triggerBrowserDownload } from '../../utils/download.js';
/** /**
* @typedef {Object} BatchResult * @typedef {Object} BatchResult
@@ -1369,32 +1370,13 @@ const fileOps = {
}, },
/** /**
* Download a file * Download a file — handed to the browser so it streams to disk with
* its native download UI instead of buffering the file in memory.
* @param {string} fileId - File ID * @param {string} fileId - File ID
* @param {string} fileName - File name * @param {string} fileName - File name
*/ */
async downloadFile(fileId, fileName) { async downloadFile(fileId, fileName) {
try { triggerBrowserDownload(`/api/files/${fileId}`, fileName);
const response = await fetch(`/api/files/${fileId}`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the file');
}
} catch (error) {
console.error('Error downloading file:', error);
ui.showNotification('Error', 'Error downloading the file');
}
}, },
/** /**
@@ -1403,30 +1385,10 @@ const fileOps = {
* @param {string} folderName - Folder name * @param {string} folderName - Folder name
*/ */
async downloadFolder(folderId, folderName) { async downloadFolder(folderId, folderName) {
try { // Show notification to user (the server still has to assemble the
// Show notification to user // ZIP before the browser's own download UI takes over).
ui.showNotification('Preparing download', 'Preparing the folder for download...'); ui.showNotification('Preparing download', 'Preparing the folder for download...');
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
const response = await fetch(`/api/folders/${folderId}/download?format=zip`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${folderName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the folder');
}
} catch (error) {
console.error('Error downloading folder:', error);
ui.showNotification('Error', 'Error downloading the folder');
}
} }
}; };
+63 -95
View File
@@ -6,6 +6,7 @@
import { updateHistory } from '../../app/main.js'; import { updateHistory } from '../../app/main.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js'; import { isTextViewable } from '../../core/formatters.js';
import { triggerBrowserDownload } from '../../utils/download.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */ /** @import {FileItem} from '../../core/types.js' */
@@ -397,111 +398,92 @@ class InlineViewer {
} }
/** /**
* Creates an audio or video player using blob URL (authenticated fetch) * Creates an audio or video player that streams straight from the API.
* The element's `src` points at the same-origin endpoint (cookies are
* sent automatically), so the browser issues Range requests and starts
* playback progressively — the file is never materialized in memory,
* and seeking works without downloading everything first.
* @param {FileItem} file * @param {FileItem} file
* @param {string} mediaType * @param {string} mediaType
* @param {HTMLDivElement} container * @param {HTMLDivElement} container
* @param {HTMLDivElement} loader * @param {HTMLDivElement} loader
*/ */
async createMediaViewer(file, mediaType, container, loader) { createMediaViewer(file, mediaType, container, loader) {
try { console.log(`Creating ${mediaType} player for:`, file.name);
console.log(`Creating ${mediaType} player for:`, file.name);
// Fetch file (cookie auto-sent) const streamUrl = `/api/files/${file.id}?inline=true`;
const response = await fetch(`/api/files/${file.id}?inline=true`, {
credentials: 'same-origin'
});
if (!response.ok) { // The native player has its own buffering UI — drop our spinner now.
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`); if (loader?.parentNode) {
} loader.parentNode.removeChild(loader);
}
const blob = await response.blob(); if (mediaType === 'audio') {
const blobUrl = URL.createObjectURL(blob); // Wrapper with icon + player
const wrapper = document.createElement('div');
wrapper.className = 'inline-viewer-audio-wrapper';
// Remove loader const icon = document.createElement('div');
if (loader?.parentNode) { icon.className = 'inline-viewer-audio-icon';
loader.parentNode.removeChild(loader); icon.innerHTML = '<i class="fas fa-music"></i>';
} wrapper.appendChild(icon);
if (mediaType === 'audio') { const nameEl = document.createElement('div');
// Wrapper with icon + player nameEl.className = 'inline-viewer-audio-name';
const wrapper = document.createElement('div'); nameEl.textContent = file.name;
wrapper.className = 'inline-viewer-audio-wrapper'; wrapper.appendChild(nameEl);
const icon = document.createElement('div'); const audio = document.createElement('audio');
icon.className = 'inline-viewer-audio-icon'; audio.className = 'inline-viewer-audio';
icon.innerHTML = '<i class="fas fa-music"></i>'; audio.controls = true;
wrapper.appendChild(icon); audio.preload = 'metadata';
audio.src = streamUrl;
wrapper.appendChild(audio);
const nameEl = document.createElement('div'); // Fallback message for unsupported codecs / failed loads
nameEl.className = 'inline-viewer-audio-name'; audio.addEventListener('error', () => {
nameEl.textContent = file.name; console.warn('Audio playback error — codec may not be supported');
wrapper.appendChild(nameEl); wrapper.innerHTML = '';
const msg = document.createElement('div');
const audio = document.createElement('audio'); msg.className = 'inline-viewer-message';
audio.className = 'inline-viewer-audio'; msg.innerHTML = `
audio.controls = true;
audio.preload = 'metadata';
audio.src = blobUrl;
wrapper.appendChild(audio);
// Fallback message for unsupported codecs
audio.addEventListener('error', () => {
console.warn('Audio playback error — codec may not be supported');
wrapper.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'inline-viewer-message';
msg.innerHTML = `
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div> <div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="inline-viewer-text"> <div class="inline-viewer-text">
<p>Your browser cannot play this audio format.</p> <p>Your browser cannot play this audio format.</p>
<p>Click "Download" to save the file.</p> <p>Click "Download" to save the file.</p>
</div> </div>
`; `;
wrapper.appendChild(msg); wrapper.appendChild(msg);
}); });
container.appendChild(wrapper); container.appendChild(wrapper);
} else { } else {
const video = document.createElement('video'); const video = document.createElement('video');
video.className = 'inline-viewer-video'; video.className = 'inline-viewer-video';
video.controls = true; video.controls = true;
video.preload = 'metadata'; video.preload = 'metadata';
video.src = blobUrl; video.src = streamUrl;
video.setAttribute('playsinline', 'true'); video.setAttribute('playsinline', 'true');
// Fallback message for unsupported codecs // Fallback message for unsupported codecs / failed loads
video.addEventListener('error', () => { video.addEventListener('error', () => {
console.warn('Video playback error — codec may not be supported'); console.warn('Video playback error — codec may not be supported');
if (video.parentNode) { if (video.parentNode) {
video.parentNode.removeChild(video); video.parentNode.removeChild(video);
} }
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.className = 'inline-viewer-message'; msg.className = 'inline-viewer-message';
msg.innerHTML = ` msg.innerHTML = `
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div> <div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="inline-viewer-text"> <div class="inline-viewer-text">
<p>Your browser cannot play this video format.</p> <p>Your browser cannot play this video format.</p>
<p>Click "Download" to save the file.</p> <p>Click "Download" to save the file.</p>
</div> </div>
`; `;
container.appendChild(msg); container.appendChild(msg);
}); });
container.appendChild(video); container.appendChild(video);
}
// Store blob URL for cleanup on close
this.currentBlobUrl = blobUrl;
} catch (error) {
console.error(`Error creating ${mediaType} viewer:`, error);
if (loader?.parentNode) {
loader.parentNode.removeChild(loader);
}
this.showErrorMessage(container);
} }
} }
@@ -549,26 +531,12 @@ class InlineViewer {
} }
/** /**
* * Download the file via a browser-native download (streams to disk,
* nothing is buffered in page memory).
* @param {FileItem} file * @param {FileItem} file
*/ */
downloadFile(file) { downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) triggerBrowserDownload(`/api/files/${file.id}`, file.name);
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch((err) => console.error('Download error:', err));
} }
/** /**
+44
View File
@@ -0,0 +1,44 @@
// @ts-check
/**
* Browser-native download helpers.
*
* Downloads are handed to the browser as same-origin navigations: the
* response streams straight to disk with the browser's own progress UI,
* and auth cookies travel automatically. Nothing is buffered in page
* memory — unlike the old `fetch → blob → objectURL` pattern, which
* materialized the entire payload in the tab's heap before the save
* dialog could even appear.
*/
/**
* Trigger a browser-native download for a same-origin URL.
*
* @param {string} url - Same-origin URL of the resource to download
* @param {string} [filename] - Suggested file name. The server's
* `Content-Disposition` filename wins when present; an empty string
* keeps whatever the server (or URL) provides.
*/
export function triggerBrowserDownload(url, filename = '') {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Build the GET URL for the batch ZIP download endpoint
* (`GET /api/batch/download` accepts comma-separated id lists).
* Shared by the batch toolbar download and the drag-out `DownloadURL`
* builder so both stay in sync with the endpoint's query contract.
*
* @param {string[]} fileIds
* @param {string[]} folderIds
* @returns {string} Root-relative URL (prepend `window.location.origin`
* when an absolute URL is required, e.g. for `DataTransfer.setData`).
*/
export function buildBatchDownloadUrl(fileIds, folderIds) {
return `/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`;
}