mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd8e599de | ||
|
|
a62cf80bdf | ||
|
|
841d283135 | ||
|
|
c6350f1a7b | ||
|
|
fedc38faa5 | ||
|
|
2411a2baaf | ||
|
|
81fb4ee84c | ||
|
|
24c2808a00 | ||
|
|
abc8b15d63 | ||
|
|
d767802c6a | ||
|
|
3cbc499324 | ||
|
|
8ca6ecc9ca |
No files matched your search
@@ -23,7 +23,6 @@ import { createUploadThumbnailGenerator } from '../../services/pdfThumbnails/ind
|
||||
import path from '../../lib/path.js';
|
||||
import open_item from '../../helpers/openItem.js';
|
||||
import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIWindowProgress from '../UIWindowProgress.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import generate_file_context_menu from '../../helpers/generateFileContextMenu.js';
|
||||
import update_title_based_on_uploads from '../../helpers/updateTitleBasedOnUploads.js';
|
||||
@@ -35,6 +34,8 @@ import publish_as_website from '../../helpers/publishAsWebsite.js';
|
||||
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import UIItemPropertiesModal from './UIItemPropertiesModal.js';
|
||||
import UIShareModal from './UIShareModal.js';
|
||||
import UIDashboardTransfer from './UIDashboardTransfers.js';
|
||||
import { summarizeUpload } from './transferProgress.js';
|
||||
import { dedupedName } from './dedupedName.js';
|
||||
import { middleTruncate } from './middleTruncate.js';
|
||||
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
|
||||
@@ -1512,87 +1513,7 @@ const TabFiles = {
|
||||
_this.uploadTargetPath = null;
|
||||
if ( files.length === 0 ) return;
|
||||
if ( ! _this.acceptsNewItems(destination) ) return;
|
||||
|
||||
let upload_progress_window;
|
||||
let opid;
|
||||
|
||||
puter.fs.upload(files, destination, {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
init: async (operation_id, xhr) => {
|
||||
opid = operation_id;
|
||||
// register before the first await, so a failure while the progress
|
||||
// window is still opening can't delete the entry before it exists
|
||||
window.active_uploads[opid] = 0;
|
||||
// create upload progress window
|
||||
upload_progress_window = await UIWindowProgress({
|
||||
title: i18n('upload'),
|
||||
icon: window.icons['app-icon-uploader.svg'],
|
||||
operation_id: operation_id,
|
||||
show_progress: true,
|
||||
on_cancel: () => {
|
||||
window.show_save_account_notice_if_needed();
|
||||
xhr.abort();
|
||||
},
|
||||
});
|
||||
},
|
||||
// start
|
||||
start: async function () {
|
||||
// change upload progress window message to uploading
|
||||
upload_progress_window.set_status(i18n('uploading'));
|
||||
upload_progress_window.set_progress(0);
|
||||
},
|
||||
// progress
|
||||
progress: async function (operation_id, op_progress) {
|
||||
upload_progress_window.set_progress(op_progress);
|
||||
// update active_uploads
|
||||
window.active_uploads[opid] = op_progress;
|
||||
// update title if window is not visible
|
||||
if ( document.visibilityState !== 'visible' ) {
|
||||
update_title_based_on_uploads();
|
||||
}
|
||||
},
|
||||
// success
|
||||
success: async function (items) {
|
||||
// Add action to actions_history for undo ability
|
||||
const files = [];
|
||||
if ( typeof items[Symbol.iterator] === 'function' ) {
|
||||
for ( const item of items ) {
|
||||
files.push(item.path);
|
||||
}
|
||||
} else {
|
||||
files.push(items.path);
|
||||
}
|
||||
window.actions_history.push({
|
||||
operation: 'upload',
|
||||
data: files,
|
||||
});
|
||||
setTimeout(() => {
|
||||
upload_progress_window.close();
|
||||
}, 1000);
|
||||
window.show_save_account_notice_if_needed();
|
||||
// remove from active_uploads
|
||||
delete window.active_uploads[opid];
|
||||
// refresh, then highlight the uploaded items
|
||||
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
|
||||
_this.selectUploadedRows(files);
|
||||
},
|
||||
// error
|
||||
error: async function (err) {
|
||||
const failedMessage = failedUploadsMessage(err);
|
||||
if ( failedMessage ) UIAlert(failedMessage);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
|
||||
// remove from active_uploads
|
||||
delete window.active_uploads[opid];
|
||||
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
|
||||
},
|
||||
// abort
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
abort: async function (operation_id) {
|
||||
// remove from active_uploads
|
||||
delete window.active_uploads[opid];
|
||||
},
|
||||
});
|
||||
_this.uploadFiles(files, destination);
|
||||
};
|
||||
|
||||
// Upload button
|
||||
@@ -5570,13 +5491,11 @@ const TabFiles = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads files to the specified destination path.
|
||||
* Uploads files to a folder with a row in the transfer tray, then refreshes the
|
||||
* listing and highlights what arrived.
|
||||
*
|
||||
* This method handles the complete upload flow including progress modal,
|
||||
* error handling, and directory refresh on completion. Used by drag-drop
|
||||
* upload handlers to ensure the Dashboard view updates after uploads.
|
||||
*
|
||||
* @param {DataTransferItemList|FileList} items - The files to upload
|
||||
* @param {DataTransferItemList|FileList|File[]} items - The files to upload;
|
||||
* a drop's list must be passed while its event is still dispatching
|
||||
* @param {string} destPath - The destination directory path
|
||||
* @returns {void}
|
||||
*/
|
||||
@@ -5590,19 +5509,23 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { count, name } = summarizeUpload(items);
|
||||
const single = count === 1 && name;
|
||||
const title = single ? i18n('uploading_file', [name], false) : i18n('uploading_items', { count }, false);
|
||||
const doneTitle = single ? i18n('uploaded_file', [name], false) : i18n('uploaded_items', { count }, false);
|
||||
const failedTitle = single ? i18n('upload_failed_file', [name], false) : i18n('upload_failed_items', { count }, false);
|
||||
const status = i18n('progress_destination', [this.historyLabel(destPath)]);
|
||||
|
||||
puter.fs.upload(items, destPath, {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
init: async (operation_id, xhr) => {
|
||||
init: (operation_id, xhr) => {
|
||||
opid = operation_id;
|
||||
// register before the first await, so a failure while the progress
|
||||
// window is still opening can't delete the entry before it exists
|
||||
window.active_uploads[opid] = 0;
|
||||
upload_progress_window = await UIWindowProgress({
|
||||
title: i18n('upload'),
|
||||
icon: window.icons['app-icon-uploader.svg'],
|
||||
operation_id: operation_id,
|
||||
show_progress: true,
|
||||
upload_progress_window = UIDashboardTransfer({
|
||||
title,
|
||||
status,
|
||||
operation_id,
|
||||
on_cancel: () => {
|
||||
window.show_save_account_notice_if_needed();
|
||||
xhr.abort();
|
||||
@@ -5610,7 +5533,6 @@ const TabFiles = {
|
||||
});
|
||||
},
|
||||
start: async function () {
|
||||
upload_progress_window.set_status(i18n('uploading'));
|
||||
upload_progress_window.set_progress(0);
|
||||
},
|
||||
progress: async function (_operation_id, op_progress) {
|
||||
@@ -5633,9 +5555,12 @@ const TabFiles = {
|
||||
operation: 'upload',
|
||||
data: files,
|
||||
});
|
||||
// The tray doesn't block anything, so the done state can
|
||||
// stay long enough to be noticed.
|
||||
upload_progress_window.complete(doneTitle);
|
||||
setTimeout(() => {
|
||||
upload_progress_window.close();
|
||||
}, 1000);
|
||||
}, 4000);
|
||||
window.show_save_account_notice_if_needed();
|
||||
delete window.active_uploads[opid];
|
||||
// Refresh directory to show uploaded files, then highlight
|
||||
@@ -5647,7 +5572,7 @@ const TabFiles = {
|
||||
error: async function (err) {
|
||||
const failedMessage = failedUploadsMessage(err);
|
||||
if ( failedMessage ) UIAlert(failedMessage);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
|
||||
upload_progress_window.show_error(failedTitle, err.message);
|
||||
delete window.active_uploads[opid];
|
||||
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
// The tab's window and dialog plumbing isn't what these tests exercise.
|
||||
vi.mock('../UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The dashboard's progress for uploads, copies and moves: a row in a tray in
|
||||
// the bottom-right corner that leaves the rest of the dashboard usable. Each
|
||||
// row has the handle UIWindowProgress returns, so the shared file helpers
|
||||
// drive either.
|
||||
|
||||
import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
import { clampPercent, createRemainingEstimator, roundSecondsLeft } from './transferProgress.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
const TRAY_ICONS = {
|
||||
done: SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>'),
|
||||
chevron: SVG('<path d="M6 9l6 6 6-6"/>'),
|
||||
};
|
||||
|
||||
// Matches the row's fade-out in dashboard.css.
|
||||
const LEAVE_MS = 200;
|
||||
|
||||
/**
|
||||
* @typedef {object} DashboardTransfer
|
||||
* @property {HTMLElement} element The task's row.
|
||||
* @property {(html: string) => void} set_status HTML the caller has already encoded.
|
||||
* @property {(percent: number|string) => void} set_progress The first call
|
||||
* turns the bar from indeterminate to a percentage with an estimate of the time left.
|
||||
* @property {(title?: string) => void} complete Fills the bar and shows a done
|
||||
* state until `close`; `title` (plain text) replaces the row's title.
|
||||
* @property {(title: string, message: string) => void} show_error Plain text;
|
||||
* the row stays until the user dismisses it.
|
||||
* @property {() => void} close Removes the row.
|
||||
*/
|
||||
|
||||
/** @type {jQuery | null} */
|
||||
let $tray = null;
|
||||
let collapsed = false;
|
||||
|
||||
const rows = () => $tray?.find('.dashboard-transfer:not(.dashboard-transfer-leaving)') ?? $();
|
||||
|
||||
const countText = (prefix, count) =>
|
||||
i18n(count === 1 ? `${prefix}_one` : `${prefix}_other`, { count }, false);
|
||||
|
||||
function refreshHeader () {
|
||||
if ( ! $tray ) return;
|
||||
const $rows = rows();
|
||||
const running = $rows.not('.dashboard-transfer-done, .dashboard-transfer-error').length;
|
||||
const failed = $rows.filter('.dashboard-transfer-error').length;
|
||||
let summary;
|
||||
if ( running > 0 ) summary = countText('tasks_in_progress', running);
|
||||
else if ( failed > 0 ) summary = countText('tasks_failed', failed);
|
||||
else summary = i18n('tasks_done', [], false);
|
||||
$tray.find('.dashboard-transfers-summary').text(summary);
|
||||
// Running tasks can be hidden but not cleared; only Cancel stops one.
|
||||
$tray.find('.dashboard-transfers-close').prop('hidden', running > 0);
|
||||
}
|
||||
|
||||
function setCollapsed (value) {
|
||||
collapsed = value;
|
||||
const label = i18n(collapsed ? 'tasks_expand' : 'tasks_collapse', [], false);
|
||||
$tray.toggleClass('dashboard-transfers-collapsed', collapsed);
|
||||
$tray.find('.dashboard-transfers-toggle').attr({ 'aria-expanded': String(! collapsed), 'aria-label': label, title: label });
|
||||
}
|
||||
|
||||
function mountTray () {
|
||||
if ( $tray?.get(0).isConnected ) return $tray;
|
||||
const id = `dashboard-transfers-${window.uuidv4?.() ?? Date.now()}`;
|
||||
let h = `<section class="dashboard-transfers" aria-labelledby="${id}-summary">`;
|
||||
h += '<div class="dashboard-transfers-header">';
|
||||
// Announces tasks starting and finishing; a failure's row is its own alert.
|
||||
h += `<span class="dashboard-transfers-summary" id="${id}-summary" role="status"></span>`;
|
||||
h += `<button type="button" class="dashboard-transfers-btn dashboard-transfers-toggle" aria-controls="${id}-list">${TRAY_ICONS.chevron}</button>`;
|
||||
h += `<button type="button" class="dashboard-transfers-btn dashboard-transfers-close" aria-label="${i18n('close')}" title="${i18n('close')}" hidden>${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
h += `<ul class="dashboard-transfers-list" id="${id}-list"></ul>`;
|
||||
h += '</section>';
|
||||
|
||||
// Inside the dashboard, so its drawer, sheets and dialogs stack above the tray.
|
||||
const $dashboard = $('.window-dashboard .dashboard').first();
|
||||
$tray = $(h).appendTo($dashboard.length ? $dashboard : 'body');
|
||||
setCollapsed(collapsed);
|
||||
requestAnimationFrame(() => $tray?.addClass('dashboard-transfers-show'));
|
||||
|
||||
$tray.on('click', '.dashboard-transfers-toggle', () => setCollapsed(! collapsed));
|
||||
$tray.on('click', '.dashboard-transfers-close', () => {
|
||||
rows().each(function () {
|
||||
this.dashboardTransferClose?.();
|
||||
});
|
||||
});
|
||||
return $tray;
|
||||
}
|
||||
|
||||
/** Takes a row out, and the tray with the last one. */
|
||||
function removeRow ($row) {
|
||||
if ( $row.hasClass('dashboard-transfer-leaving') ) return;
|
||||
// Keep focus in the tray rather than letting it drop to the page.
|
||||
const had_focus = $row.get(0).contains(document.activeElement);
|
||||
$row.addClass('dashboard-transfer-leaving');
|
||||
refreshHeader();
|
||||
if ( had_focus ) $tray.find('.dashboard-transfers-toggle').get(0)?.focus({ preventScroll: true });
|
||||
|
||||
setTimeout(() => {
|
||||
$row.remove();
|
||||
if ( $tray && rows().length === 0 && $tray.find('.dashboard-transfer').length === 0 ) {
|
||||
const $old = $tray;
|
||||
$tray = null;
|
||||
collapsed = false;
|
||||
$old.removeClass('dashboard-transfers-show');
|
||||
setTimeout(() => $old.remove(), LEAVE_MS);
|
||||
}
|
||||
}, LEAVE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a task to the tray, opening the tray if it isn't up.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.title] Plain text, such as "Uploading 3 items".
|
||||
* @param {string} [opts.status] HTML the caller has already encoded; defaults to "Preparing...".
|
||||
* @param {number|string} [opts.operation_id] Saved in `data-operation-id` for later lookup.
|
||||
* @param {() => void} [opts.on_cancel] Runs after the row's Cancel button
|
||||
* removes it. Without it the row has no Cancel button.
|
||||
* @returns {DashboardTransfer}
|
||||
*/
|
||||
export default function UIDashboardTransfer ({ title, status, operation_id, on_cancel } = {}) {
|
||||
const id = `dashboard-transfer-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = `<li class="dashboard-transfer" aria-labelledby="${id}-title"`;
|
||||
if ( operation_id !== null && operation_id !== undefined ) h += ` data-operation-id="${html_encode(String(operation_id))}"`;
|
||||
h += '>';
|
||||
h += '<div class="dashboard-transfer-row">';
|
||||
h += '<div class="dashboard-transfer-text">';
|
||||
// A task without a title (emptying the Trash) leads with its status.
|
||||
h += `<span class="dashboard-transfer-title" id="${id}-title">${title ? html_encode(title) : ''}</span>`;
|
||||
h += `<span class="dashboard-transfer-status">${status ?? i18n('preparing')}</span>`;
|
||||
h += '</div>';
|
||||
if ( on_cancel ) {
|
||||
h += `<button type="button" class="dashboard-transfers-btn dashboard-transfer-cancel" aria-label="${i18n('cancel')}" title="${i18n('cancel')}" aria-describedby="${id}-title">${ICONS.close}</button>`;
|
||||
}
|
||||
h += '</div>';
|
||||
// No aria-valuenow until there is a percentage: that reads as indeterminate.
|
||||
h += `<div class="dashboard-progress-track dashboard-progress-indeterminate" role="progressbar" aria-labelledby="${id}-title" aria-valuemin="0" aria-valuemax="100">`;
|
||||
h += '<div class="dashboard-progress-fill"></div>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-progress-meta" hidden>';
|
||||
h += '<span class="dashboard-progress-eta"></span>';
|
||||
h += '<span class="dashboard-progress-percent"></span>';
|
||||
h += '</div>';
|
||||
h += '</li>';
|
||||
|
||||
const $row = $(h).appendTo(mountTray().find('.dashboard-transfers-list'));
|
||||
const el_row = $row.get(0);
|
||||
const $title = $row.find('.dashboard-transfer-title');
|
||||
const $status = $row.find('.dashboard-transfer-status');
|
||||
if ( ! title ) {
|
||||
$title.html($status.html());
|
||||
$status.remove();
|
||||
}
|
||||
refreshHeader();
|
||||
|
||||
const $track = $row.find('.dashboard-progress-track');
|
||||
const $fill = $row.find('.dashboard-progress-fill');
|
||||
const $meta = $row.find('.dashboard-progress-meta');
|
||||
const $eta = $row.find('.dashboard-progress-eta');
|
||||
const $percent = $row.find('.dashboard-progress-percent');
|
||||
const estimator = createRemainingEstimator();
|
||||
|
||||
let settled = false;
|
||||
const isClosed = () => $row.hasClass('dashboard-transfer-leaving') || ! el_row.isConnected;
|
||||
const close = () => removeRow($row);
|
||||
el_row.dashboardTransferClose = close;
|
||||
|
||||
const time_left = (seconds) => {
|
||||
const rounded = seconds === null ? null : roundSecondsLeft(seconds);
|
||||
if ( ! rounded ) return i18n('progress_few_seconds_left', [], false);
|
||||
let time;
|
||||
try {
|
||||
time = new Intl.NumberFormat(intlLocale(window.locale), { style: 'unit', unit: rounded.unit, unitDisplay: 'long' }).format(rounded.value);
|
||||
} catch {
|
||||
time = String(rounded.value);
|
||||
}
|
||||
return i18n('progress_time_left', { time }, false);
|
||||
};
|
||||
|
||||
const fill_to = (pct) => {
|
||||
$track.removeClass('dashboard-progress-indeterminate').attr('aria-valuenow', pct);
|
||||
$fill.css('transform', `scaleX(${pct / 100})`);
|
||||
$percent.text(`${pct}%`);
|
||||
$meta.prop('hidden', false);
|
||||
};
|
||||
|
||||
$row.on('click', '.dashboard-transfer-cancel', () => {
|
||||
// The row stays clickable while it fades out; a double-click cancels once.
|
||||
if ( isClosed() ) return;
|
||||
close();
|
||||
on_cancel();
|
||||
});
|
||||
|
||||
return {
|
||||
element: el_row,
|
||||
set_status (html) {
|
||||
if ( settled ) return;
|
||||
(title ? $status : $title).html(html);
|
||||
},
|
||||
set_progress (percent) {
|
||||
if ( settled || isClosed() ) return;
|
||||
// Rounding first would hide a slow transfer's progress from the estimate.
|
||||
const exact = clampPercent(percent);
|
||||
const pct = Math.round(exact);
|
||||
const seconds = estimator.push(exact);
|
||||
fill_to(pct);
|
||||
// Every byte is sent, but the server hasn't confirmed the write yet.
|
||||
if ( pct >= 100 ) $eta.text(i18n('progress_finishing', [], false));
|
||||
else if ( seconds === null ) $eta.text('');
|
||||
else $eta.text(time_left(seconds));
|
||||
},
|
||||
complete (done_title) {
|
||||
if ( settled || isClosed() ) return;
|
||||
settled = true;
|
||||
fill_to(100);
|
||||
$row.addClass('dashboard-transfer-done');
|
||||
$eta.html(`${TRAY_ICONS.done}<span>${i18n('done')}</span>`);
|
||||
if ( done_title ) $title.text(done_title);
|
||||
$row.find('.dashboard-transfer-cancel').remove();
|
||||
refreshHeader();
|
||||
},
|
||||
close,
|
||||
show_error (error_title, message) {
|
||||
if ( isClosed() ) return;
|
||||
settled = true;
|
||||
$row.addClass('dashboard-transfer-error');
|
||||
let body = '<div class="dashboard-transfer-row">';
|
||||
body += `<span class="dashboard-transfer-icon">${ICONS.error}</span>`;
|
||||
body += '<div class="dashboard-transfer-text" role="alert">';
|
||||
body += `<span class="dashboard-transfer-title" id="${id}-title">${html_encode(error_title ?? '')}</span>`;
|
||||
if ( message ) body += `<span class="dashboard-transfer-status">${html_encode(message)}</span>`;
|
||||
body += '</div>';
|
||||
body += `<button type="button" class="dashboard-transfers-btn dashboard-transfer-dismiss" aria-label="${i18n('dismiss')}" title="${i18n('dismiss')}" aria-describedby="${id}-title">${ICONS.close}</button>`;
|
||||
body += '</div>';
|
||||
// The Cancel button may have had focus; the Dismiss button takes it over.
|
||||
const had_focus = el_row.contains(document.activeElement);
|
||||
$row.html(body);
|
||||
$row.find('.dashboard-transfer-dismiss').on('click', close);
|
||||
if ( had_focus ) $row.find('.dashboard-transfer-dismiss').get(0).focus({ preventScroll: true });
|
||||
// A collapsed tray would hide the failure behind its summary.
|
||||
if ( collapsed ) setCollapsed(false);
|
||||
refreshHeader();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, replacements) => {
|
||||
if ( Array.isArray(replacements) && replacements.length ) return `${key}:${replacements.join()}`;
|
||||
if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) return `${key}:${JSON.stringify(replacements)}`;
|
||||
return key;
|
||||
};
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
|
||||
const { default: UIDashboardTransfer } = await import('./UIDashboardTransfers.js');
|
||||
|
||||
const summary = () => $('.dashboard-transfers-summary').text();
|
||||
// Lets a removed row (and then the tray) finish fading out.
|
||||
const settle = () => vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
$('.dashboard-transfer').each(function () {
|
||||
this.dashboardTransferClose?.();
|
||||
});
|
||||
await settle();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('UIDashboardTransfer', () => {
|
||||
it('adds a row to a tray without blocking the page', () => {
|
||||
const transfer = UIDashboardTransfer({ title: 'Copying', operation_id: 7 });
|
||||
expect($('.dashboard-transfers .dashboard-transfer')).toHaveLength(1);
|
||||
expect($('.dashboard-dialog-overlay, .window')).toHaveLength(0);
|
||||
expect($('.dashboard-transfer-title').text()).toBe('Copying');
|
||||
expect($('.dashboard-transfer-status').text()).toBe('preparing');
|
||||
expect($(transfer.element).attr('data-operation-id')).toBe('7');
|
||||
expect(summary()).toBe('tasks_in_progress_one:{"count":1}');
|
||||
expect($('.dashboard-transfers-summary').attr('role')).toBe('status');
|
||||
const $bar = $('[role="progressbar"]');
|
||||
expect($bar.hasClass('dashboard-progress-indeterminate')).toBe(true);
|
||||
expect($bar.attr('aria-valuenow')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mounts inside the dashboard, under its drawer and dialogs', () => {
|
||||
$('<div class="window window-dashboard"><div class="dashboard"></div></div>').appendTo('body');
|
||||
UIDashboardTransfer({ title: 'Copying' });
|
||||
expect($('.window-dashboard .dashboard > .dashboard-transfers')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mounts a new tray when the old one was taken out of the page', () => {
|
||||
UIDashboardTransfer({ title: 'Copying' });
|
||||
$('.dashboard-transfers').detach();
|
||||
UIDashboardTransfer({ title: 'Moving' });
|
||||
expect($('.dashboard-transfers')).toHaveLength(1);
|
||||
expect($('.dashboard-transfer-title').text()).toBe('Moving');
|
||||
});
|
||||
|
||||
it('does not take focus', () => {
|
||||
const $input = $('<input>').appendTo('body');
|
||||
$input.get(0).focus();
|
||||
UIDashboardTransfer({ title: 'Uploading', on_cancel: () => {} });
|
||||
expect(document.activeElement).toBe($input.get(0));
|
||||
});
|
||||
|
||||
it('shares one tray between tasks and counts the running ones', () => {
|
||||
const a = UIDashboardTransfer({ title: 'Uploading' });
|
||||
UIDashboardTransfer({ title: 'Copying' });
|
||||
expect($('.dashboard-transfers')).toHaveLength(1);
|
||||
expect(summary()).toBe('tasks_in_progress_other:{"count":2}');
|
||||
a.complete();
|
||||
expect(summary()).toBe('tasks_in_progress_one:{"count":1}');
|
||||
expect($('.dashboard-transfers-close').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('leads with the status when there is no title', () => {
|
||||
const transfer = UIDashboardTransfer({ status: 'Emptying' });
|
||||
expect($('.dashboard-transfer-title').text()).toBe('Emptying');
|
||||
expect($('.dashboard-transfer-status')).toHaveLength(0);
|
||||
transfer.set_status('Still emptying');
|
||||
expect($('.dashboard-transfer-title').text()).toBe('Still emptying');
|
||||
});
|
||||
|
||||
it('turns determinate on the first percentage, clamping what callers pass', () => {
|
||||
const transfer = UIDashboardTransfer({ title: 'Uploading' });
|
||||
transfer.set_progress('1.0e+2');
|
||||
const $bar = $('[role="progressbar"]');
|
||||
expect($bar.hasClass('dashboard-progress-indeterminate')).toBe(false);
|
||||
expect($bar.attr('aria-valuenow')).toBe('100');
|
||||
expect($('.dashboard-progress-percent').text()).toBe('100%');
|
||||
expect($('.dashboard-progress-eta').text()).toBe('progress_finishing');
|
||||
|
||||
transfer.set_progress(42.4);
|
||||
expect($bar.attr('aria-valuenow')).toBe('42');
|
||||
expect($('.dashboard-progress-fill').get(0).style.transform).toBe('scaleX(0.42)');
|
||||
});
|
||||
|
||||
it('keeps a time-left estimate for a slow transfer', async () => {
|
||||
// The uploader reports to the hundredth every 100ms; at 0.15%/s the
|
||||
// whole percentage can stand still for longer than the rate window.
|
||||
const transfer = UIDashboardTransfer({ title: 'Uploading' });
|
||||
let blank = 0;
|
||||
for ( let t = 0; t <= 60_000; t += 100 ) {
|
||||
transfer.set_progress((0.15 * t / 1000).toFixed(2));
|
||||
if ( t >= 3000 && $('.dashboard-progress-eta').text() === '' ) blank++;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
expect(blank).toBe(0);
|
||||
});
|
||||
|
||||
it('words the time left in the user\'s language, Puter codes included', async () => {
|
||||
window.locale = 'zhtw';
|
||||
try {
|
||||
const transfer = UIDashboardTransfer({ title: 'Uploading' });
|
||||
for ( let pct = 0; pct <= 10; pct++ ) {
|
||||
transfer.set_progress(pct / 5);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect($('.dashboard-progress-eta').text()).toMatch(/分鐘/);
|
||||
} finally {
|
||||
window.locale = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the caller\'s encoded status as HTML', () => {
|
||||
const transfer = UIDashboardTransfer({ title: 'Moving' });
|
||||
transfer.set_status('Moving <strong>a&b</strong>');
|
||||
expect($('.dashboard-transfer-status strong').text()).toBe('a&b');
|
||||
});
|
||||
|
||||
it('cancels from its row and drops the tray with its last row', async () => {
|
||||
const on_cancel = vi.fn();
|
||||
UIDashboardTransfer({ title: 'Uploading', on_cancel });
|
||||
expect($('.dashboard-transfer-cancel').attr('aria-describedby')).toBe($('.dashboard-transfer-title').attr('id'));
|
||||
$('.dashboard-transfer-cancel').trigger('click');
|
||||
expect(on_cancel).toHaveBeenCalledOnce();
|
||||
await settle();
|
||||
expect($('.dashboard-transfers')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancels once when Cancel is clicked twice', () => {
|
||||
const on_cancel = vi.fn();
|
||||
UIDashboardTransfer({ title: 'Uploading', on_cancel });
|
||||
$('.dashboard-transfer-cancel').trigger('click').trigger('click');
|
||||
expect(on_cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('has no Cancel button without on_cancel', () => {
|
||||
UIDashboardTransfer({ title: 'Deleting' });
|
||||
expect($('.dashboard-transfer-cancel')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows a done state that ignores later updates', () => {
|
||||
const transfer = UIDashboardTransfer({ title: 'Uploading', on_cancel: () => {} });
|
||||
transfer.set_progress(60);
|
||||
transfer.complete();
|
||||
expect($('.dashboard-transfer').hasClass('dashboard-transfer-done')).toBe(true);
|
||||
expect($('.dashboard-progress-percent').text()).toBe('100%');
|
||||
expect($('.dashboard-progress-eta').text()).toBe('done');
|
||||
expect($('.dashboard-transfer-cancel')).toHaveLength(0);
|
||||
transfer.set_progress(10);
|
||||
expect($('.dashboard-progress-percent').text()).toBe('100%');
|
||||
expect(summary()).toBe('tasks_done');
|
||||
expect($('.dashboard-transfers-close').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('retitles a finished task as plain text', () => {
|
||||
UIDashboardTransfer({ title: 'Uploading a.txt', status: 'To <strong>Home</strong>' }).complete('Uploaded <a>.txt');
|
||||
expect($('.dashboard-transfer-title').text()).toBe('Uploaded <a>.txt');
|
||||
expect($('.dashboard-transfer-status strong').text()).toBe('Home');
|
||||
});
|
||||
|
||||
it('keeps a failure until it is dismissed, and opens a collapsed tray for it', async () => {
|
||||
const transfer = UIDashboardTransfer({ title: 'Uploading', on_cancel: () => {} });
|
||||
$('.dashboard-transfers-toggle').trigger('click');
|
||||
expect($('.dashboard-transfers').hasClass('dashboard-transfers-collapsed')).toBe(true);
|
||||
|
||||
transfer.show_error('Failed to upload', 'Disk <full>');
|
||||
expect($('.dashboard-transfers').hasClass('dashboard-transfers-collapsed')).toBe(false);
|
||||
expect($('[role="alert"]').text()).toContain('Disk <full>');
|
||||
expect($('[role="progressbar"], .dashboard-transfer-cancel')).toHaveLength(0);
|
||||
expect(summary()).toBe('tasks_failed_one:{"count":1}');
|
||||
|
||||
transfer.set_status('later');
|
||||
expect($('[role="alert"]').text()).not.toContain('later');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect($('.dashboard-transfer-error')).toHaveLength(1);
|
||||
$('.dashboard-transfer-dismiss').trigger('click');
|
||||
await settle();
|
||||
expect($('.dashboard-transfers')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('collapses to its summary and back', () => {
|
||||
UIDashboardTransfer({ title: 'Copying' });
|
||||
const $toggle = $('.dashboard-transfers-toggle');
|
||||
expect($toggle.attr('aria-expanded')).toBe('true');
|
||||
$toggle.trigger('click');
|
||||
expect($toggle.attr('aria-expanded')).toBe('false');
|
||||
expect($toggle.attr('aria-label')).toBe('tasks_expand');
|
||||
$toggle.trigger('click');
|
||||
expect($toggle.attr('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('clears finished rows from the header once nothing is running', async () => {
|
||||
const a = UIDashboardTransfer({ title: 'Uploading' });
|
||||
const b = UIDashboardTransfer({ title: 'Copying' });
|
||||
a.complete();
|
||||
b.show_error('Failed', '');
|
||||
$('.dashboard-transfers-close').trigger('click');
|
||||
await settle();
|
||||
expect($('.dashboard-transfers')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Rate is measured over the last few seconds only, so a transfer that speeds
|
||||
// up or slows down is reflected without the start of it skewing the estimate.
|
||||
const RATE_WINDOW_MS = 5000;
|
||||
// An estimate from the first second or two swings wildly; show none until then.
|
||||
const SETTLE_MS = 2000;
|
||||
const SMOOTHING = 0.3;
|
||||
|
||||
/**
|
||||
* Clamps a caller's progress to a percentage. Callers pass numbers and
|
||||
* strings alike, some from `toPrecision()` (`"1.0e+2"` for 100).
|
||||
*
|
||||
* @param {number|string} percent
|
||||
* @returns {number} 0 to 100
|
||||
*/
|
||||
export function clampPercent (percent) {
|
||||
const pct = Number(percent);
|
||||
if ( ! Number.isFinite(pct) ) return 0;
|
||||
return Math.min(100, Math.max(0, pct));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the time left in a transfer from the progress reported so far.
|
||||
*
|
||||
* @param {() => number} [now] Clock in milliseconds.
|
||||
* @returns {{ push: (percent: number) => number | null }} `push` records a
|
||||
* progress sample and returns the smoothed seconds left, or null while
|
||||
* there isn't enough to go on (just started, stalled, or finished).
|
||||
*/
|
||||
export function createRemainingEstimator (now = () => Date.now()) {
|
||||
/** @type {Array<{ t: number, percent: number }>} */
|
||||
const samples = [];
|
||||
let first_at = null;
|
||||
let smoothed = null;
|
||||
|
||||
return {
|
||||
push (percent) {
|
||||
const t = now();
|
||||
first_at ??= t;
|
||||
samples.push({ t, percent });
|
||||
while ( samples.length > 2 && t - samples[0].t > RATE_WINDOW_MS ) samples.shift();
|
||||
|
||||
if ( percent >= 100 || t - first_at < SETTLE_MS ) return null;
|
||||
const oldest = samples[0];
|
||||
const rate = (percent - oldest.percent) / (t - oldest.t);
|
||||
if ( ! (rate > 0) ) return null;
|
||||
|
||||
const seconds = (100 - percent) / rate / 1000;
|
||||
smoothed = smoothed === null ? seconds : smoothed + SMOOTHING * (seconds - smoothed);
|
||||
return smoothed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds seconds left to what a person would say: a few seconds, then
|
||||
* steps of 5 seconds, whole minutes, and whole hours, always rounding up
|
||||
* below an hour so the estimate runs out rather than overshoots.
|
||||
*
|
||||
* @param {number} seconds
|
||||
* @returns {{ value: number, unit: 'second'|'minute'|'hour' } | null} null for "a few seconds"
|
||||
*/
|
||||
export function roundSecondsLeft (seconds) {
|
||||
if ( seconds < 10 ) return null;
|
||||
if ( seconds < 60 ) return { value: Math.min(55, Math.ceil(seconds / 5) * 5), unit: 'second' };
|
||||
if ( seconds < 3600 ) return { value: Math.min(59, Math.ceil(seconds / 60)), unit: 'minute' };
|
||||
return { value: Math.max(1, Math.round(seconds / 3600)), unit: 'hour' };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many top-level items an upload carries, and the first one's name.
|
||||
* A drop's DataTransferItemList empties once its event returns, so call
|
||||
* this while the event is still dispatching.
|
||||
*
|
||||
* @param {FileList|File[]|DataTransferItemList} items
|
||||
* @returns {{ count: number, name: string }}
|
||||
*/
|
||||
export function summarizeUpload (items) {
|
||||
// A drop can carry text alongside files; only the files get uploaded.
|
||||
const files = Array.from(items ?? []).filter((item) => item && (item.kind === undefined || item.kind === 'file'));
|
||||
const first = files[0];
|
||||
const name = first?.name
|
||||
?? first?.webkitGetAsEntry?.()?.name
|
||||
?? first?.getAsFile?.()?.name
|
||||
?? '';
|
||||
return { count: files.length, name };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clampPercent, createRemainingEstimator, roundSecondsLeft, summarizeUpload } from './transferProgress.js';
|
||||
|
||||
describe('clampPercent', () => {
|
||||
it('clamps to 0-100, keeping fractions', () => {
|
||||
expect(clampPercent(42.6)).toBe(42.6);
|
||||
expect(clampPercent(-5)).toBe(0);
|
||||
expect(clampPercent(140)).toBe(100);
|
||||
});
|
||||
|
||||
it('reads strings, including toPrecision output', () => {
|
||||
expect(clampPercent('37.25')).toBe(37.25);
|
||||
expect(clampPercent((100).toPrecision(2))).toBe(100);
|
||||
expect(clampPercent('nope')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRemainingEstimator', () => {
|
||||
const clock = () => {
|
||||
let t = 0;
|
||||
return { now: () => t, advance: (ms) => { t += ms; } };
|
||||
};
|
||||
|
||||
it('stays quiet until the transfer has run long enough to judge', () => {
|
||||
const c = clock();
|
||||
const estimator = createRemainingEstimator(c.now);
|
||||
expect(estimator.push(0)).toBeNull();
|
||||
c.advance(1000);
|
||||
expect(estimator.push(10)).toBeNull();
|
||||
});
|
||||
|
||||
it('extrapolates from the recent rate', () => {
|
||||
const c = clock();
|
||||
const estimator = createRemainingEstimator(c.now);
|
||||
estimator.push(0);
|
||||
c.advance(2000);
|
||||
// 20% in 2s is 10%/s, so 80% is 8s away.
|
||||
expect(estimator.push(20)).toBeCloseTo(8);
|
||||
});
|
||||
|
||||
it('follows a transfer that speeds up', () => {
|
||||
const c = clock();
|
||||
const estimator = createRemainingEstimator(c.now);
|
||||
estimator.push(0);
|
||||
let seconds;
|
||||
for ( let i = 1; i <= 10; i++ ) {
|
||||
c.advance(1000);
|
||||
seconds = estimator.push(i);
|
||||
}
|
||||
expect(seconds).toBeGreaterThan(60);
|
||||
// Then 8%/s, which leaves about a second at 90%.
|
||||
for ( let i = 1; i <= 10; i++ ) {
|
||||
c.advance(1000);
|
||||
seconds = estimator.push(10 + i * 8);
|
||||
}
|
||||
expect(seconds).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('smooths a single jump instead of following it', () => {
|
||||
const c = clock();
|
||||
const estimator = createRemainingEstimator(c.now);
|
||||
estimator.push(0);
|
||||
c.advance(2000);
|
||||
const before = estimator.push(10);
|
||||
c.advance(100);
|
||||
const after = estimator.push(40);
|
||||
expect(after).toBeLessThan(before);
|
||||
expect(after).toBeGreaterThan(before * 0.5);
|
||||
});
|
||||
|
||||
it('gives no estimate when stalled or finished', () => {
|
||||
const c = clock();
|
||||
const estimator = createRemainingEstimator(c.now);
|
||||
estimator.push(50);
|
||||
c.advance(3000);
|
||||
expect(estimator.push(50)).toBeNull();
|
||||
c.advance(1000);
|
||||
expect(estimator.push(100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundSecondsLeft', () => {
|
||||
it('says "a few seconds" for the last stretch', () => {
|
||||
expect(roundSecondsLeft(3)).toBeNull();
|
||||
expect(roundSecondsLeft(9.9)).toBeNull();
|
||||
});
|
||||
|
||||
it('rounds up to 5 seconds, then minutes', () => {
|
||||
expect(roundSecondsLeft(11)).toEqual({ value: 15, unit: 'second' });
|
||||
expect(roundSecondsLeft(58)).toEqual({ value: 55, unit: 'second' });
|
||||
expect(roundSecondsLeft(61)).toEqual({ value: 2, unit: 'minute' });
|
||||
expect(roundSecondsLeft(3599)).toEqual({ value: 59, unit: 'minute' });
|
||||
});
|
||||
|
||||
it('rounds to whole hours past an hour', () => {
|
||||
expect(roundSecondsLeft(3600)).toEqual({ value: 1, unit: 'hour' });
|
||||
expect(roundSecondsLeft(3 * 3600 + 1000)).toEqual({ value: 3, unit: 'hour' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeUpload', () => {
|
||||
it('counts picked files and names the first', () => {
|
||||
expect(summarizeUpload([{ name: 'a.txt' }, { name: 'b.txt' }])).toEqual({ count: 2, name: 'a.txt' });
|
||||
});
|
||||
|
||||
it('reads a drop, skipping its text items', () => {
|
||||
const items = [
|
||||
{ kind: 'string' },
|
||||
{ kind: 'file', webkitGetAsEntry: () => ({ name: 'Photos' }) },
|
||||
{ kind: 'file', getAsFile: () => ({ name: 'c.png' }) },
|
||||
];
|
||||
expect(summarizeUpload(items)).toEqual({ count: 2, name: 'Photos' });
|
||||
});
|
||||
|
||||
it('handles nothing to upload', () => {
|
||||
expect(summarizeUpload(undefined)).toEqual({ count: 0, name: '' });
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,11 @@
|
||||
import UIWindow from './UIWindow.js';
|
||||
import Placeholder from '../util/Placeholder.js';
|
||||
import Button from './Components/Button.js';
|
||||
import UIDashboardTransfer from './Dashboard/UIDashboardTransfers.js';
|
||||
|
||||
/**
|
||||
* General purpose progress dialog.
|
||||
* General purpose progress dialog. The dashboard shows a row in its transfer tray instead.
|
||||
* @param title Shown as the dashboard row's title; the desktop window has none.
|
||||
* @param operation_id If provided, is saved in the data-operation-id attribute, for later lookup.
|
||||
* @param show_progress Enable a progress bar, and display `(foo%)` after the status message
|
||||
* @param on_cancel A callback run when the Cancel button is clicked. Without it, no Cancel button will appear.
|
||||
@@ -31,10 +33,15 @@ import Button from './Components/Button.js';
|
||||
* TODO: Debouncing logic (show only after a delay, then hide only after a delay)
|
||||
*/
|
||||
async function UIWindowProgress ({
|
||||
title,
|
||||
operation_id = null,
|
||||
show_progress = false,
|
||||
on_cancel = null,
|
||||
} = {}) {
|
||||
if ( window.is_dashboard_mode ) {
|
||||
return UIDashboardTransfer({ title, operation_id, on_cancel });
|
||||
}
|
||||
|
||||
const placeholder_cancel_btn = Placeholder();
|
||||
const placeholder_ok_btn = Placeholder();
|
||||
|
||||
|
||||
@@ -9212,6 +9212,317 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
|
||||
|
||||
/* ======================================
|
||||
Dashboard transfer tray (upload / copy / move)
|
||||
====================================== */
|
||||
|
||||
/* Bottom-right, out of the way of the toasts at the top, and high enough to
|
||||
leave the Files footer readable. Mounted in .dashboard: over the Files
|
||||
content, under the mobile drawer's scrim (98) and everything above it. */
|
||||
.dashboard-transfers {
|
||||
position: fixed;
|
||||
right: calc(16px + env(safe-area-inset-right, 0px));
|
||||
bottom: calc(40px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
max-height: min(60vh, 480px);
|
||||
background: var(--dashboard-card-background);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 40px var(--dashboard-shadow-medium);
|
||||
overflow: hidden;
|
||||
z-index: 97;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 200ms ease, transform 200ms ease, bottom 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Clears the Files selection bar, which select mode docks at the bottom
|
||||
(at most 40px up and 58-64px tall), so its actions stay reachable. */
|
||||
.dashboard:has(.dashboard-section-files.active .files-selection-actions.visible) > .dashboard-transfers {
|
||||
bottom: calc(110px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.dashboard-transfers-show {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dashboard-transfers-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--dashboard-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-transfers-collapsed .dashboard-transfers-header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.dashboard-transfers-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-transfers-btn {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-transfers-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-transfers-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-transfers-btn:hover {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-transfers-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* Points down to tuck the list away, up to bring it back. */
|
||||
.dashboard-transfers-toggle svg {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.dashboard-transfers-collapsed .dashboard-transfers-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dashboard-transfers-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.dashboard-transfers-collapsed .dashboard-transfers-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-transfer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 10px 12px 16px;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.dashboard-transfer + .dashboard-transfer {
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.dashboard-transfer-leaving {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dashboard-transfer-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-transfer-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Two lines, so a long file name keeps its extension in view. */
|
||||
.dashboard-transfer-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-transfer-status {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-transfer-status strong {
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
/* Lines up the 16px glyph with the title's first line. */
|
||||
.dashboard-transfer-cancel,
|
||||
.dashboard-transfer-dismiss {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.dashboard-transfer-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
margin-top: 1px;
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-transfer-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.dashboard-transfer-error .dashboard-transfer-status {
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.dashboard-progress-track {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--dashboard-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scaled rather than resized, so each update is a composited animation. */
|
||||
.dashboard-progress-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--select-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 250ms ease, background-color 200ms ease;
|
||||
}
|
||||
|
||||
.dashboard-progress-indeterminate .dashboard-progress-fill {
|
||||
width: 40%;
|
||||
border-radius: 999px;
|
||||
transition: none;
|
||||
animation: dashboard-progress-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dashboard-progress-slide {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(250%); }
|
||||
}
|
||||
|
||||
.dashboard-transfer-done .dashboard-progress-fill {
|
||||
background: var(--dashboard-success-border);
|
||||
}
|
||||
|
||||
.dashboard-progress-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: -2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-progress-meta[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-progress-eta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-transfer-done .dashboard-progress-eta {
|
||||
color: var(--dashboard-success-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-progress-eta svg {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dashboard-progress-percent {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
/* Keeps its 28px look and hit-tests at 44x44. */
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-transfers-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones: edge to edge with a small gutter. */
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-transfers {
|
||||
left: calc(8px + env(safe-area-inset-left, 0px));
|
||||
right: calc(8px + env(safe-area-inset-right, 0px));
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-transfers,
|
||||
.dashboard-transfer,
|
||||
.dashboard-transfers-toggle svg,
|
||||
.dashboard-progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.dashboard-progress-indeterminate .dashboard-progress-fill {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ======================================
|
||||
Dashboard form dialog (account changes)
|
||||
====================================== */
|
||||
|
||||
@@ -1254,7 +1254,10 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// Re-armed after a prompt: an open progress window carries on.
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
title: i18n('copying', [], false),
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
window.operation_cancelled[copy_op_id] = true;
|
||||
@@ -1396,7 +1399,10 @@ window.copy_items = function (el_items, dest_path) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// Re-armed after a prompt: an open progress window carries on.
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
title: i18n('copying', [], false),
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
window.operation_cancelled[copy_op_id] = true;
|
||||
@@ -1747,7 +1753,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// Re-armed after a prompt: an open progress window carries on.
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
title: i18n(dest_path === window.trash_path ? 'deleting' : 'moving', [], false),
|
||||
operation_id: move_op_id,
|
||||
on_cancel: () => {
|
||||
window.operation_cancelled[move_op_id] = true;
|
||||
|
||||
@@ -166,6 +166,7 @@ const en = {
|
||||
delete: 'Delete',
|
||||
delete_account: 'Delete Account',
|
||||
delete_permanently: 'Delete Permanently',
|
||||
deleting: 'Deleting',
|
||||
deleting_file: 'Deleting %%',
|
||||
deploy_as_app: 'Deploy as app',
|
||||
descending: 'Descending',
|
||||
@@ -182,6 +183,7 @@ const en = {
|
||||
directory: 'Directory',
|
||||
disassociate: 'disassociate',
|
||||
disk_storage: 'Disk Storage',
|
||||
dismiss: 'Dismiss',
|
||||
documents: 'Documents',
|
||||
done: 'Done',
|
||||
dont_allow: 'Don\'t Allow',
|
||||
@@ -271,6 +273,7 @@ const en = {
|
||||
modified: 'Modified',
|
||||
monthly_credits: 'monthly credits',
|
||||
move: 'Move',
|
||||
moving: 'Moving',
|
||||
moving_file: 'Moving %%',
|
||||
my_websites: 'My Websites',
|
||||
minimize: 'Minimize',
|
||||
@@ -411,6 +414,10 @@ const en = {
|
||||
profile_picture_save_failed: 'Your photo could not be saved. Please try again.',
|
||||
profile_save_failed: 'Your profile could not be saved. Please try again.',
|
||||
profile_saved: 'Profile saved.',
|
||||
progress_destination: 'To %strong%',
|
||||
progress_few_seconds_left: 'A few seconds left',
|
||||
progress_finishing: 'Finishing up…',
|
||||
progress_time_left: 'About {{time}} left',
|
||||
properties: 'Properties',
|
||||
public: 'Public',
|
||||
publish: 'Publish',
|
||||
@@ -671,6 +678,13 @@ const en = {
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
task_manager: 'Task Manager',
|
||||
tasks_collapse: 'Hide progress details',
|
||||
tasks_done: 'All done',
|
||||
tasks_expand: 'Show progress details',
|
||||
tasks_failed_one: '1 task failed',
|
||||
tasks_failed_other: '{{count}} tasks failed',
|
||||
tasks_in_progress_one: '1 task in progress',
|
||||
tasks_in_progress_other: '{{count}} tasks in progress',
|
||||
taskmgr_header_name: 'Name',
|
||||
taskmgr_header_status: 'Status',
|
||||
taskmgr_header_type: 'Type',
|
||||
@@ -722,6 +736,11 @@ const en = {
|
||||
upload: 'Upload',
|
||||
uploading: 'Uploading',
|
||||
uploading_file: 'Uploading %%',
|
||||
uploading_items: 'Uploading {{count}} items',
|
||||
uploaded_file: 'Uploaded %%',
|
||||
uploaded_items: 'Uploaded {{count}} items',
|
||||
upload_failed_file: 'Failed to upload %%',
|
||||
upload_failed_items: 'Failed to upload {{count}} items',
|
||||
upload_here: 'Upload here',
|
||||
uptime: 'Uptime',
|
||||
used_of: '{{used}} used of {{available}}',
|
||||
|
||||
Reference in new issue
Block a user