mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd8e599de | ||
|
|
a62cf80bdf | ||
|
|
841d283135 | ||
|
|
c6350f1a7b | ||
|
|
fedc38faa5 | ||
|
|
2411a2baaf | ||
|
|
81fb4ee84c | ||
|
|
24c2808a00 | ||
|
|
abc8b15d63 | ||
|
|
d767802c6a | ||
|
|
3cbc499324 | ||
|
|
8ca6ecc9ca | ||
|
|
24aac2f6d2 | ||
|
|
1eb4e36cf4 |
No files matched your search
@@ -950,7 +950,8 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
it('ignores an app-issued access token even for the target app', async () => {
|
||||
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
@@ -970,11 +971,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
it('ignores a user-issued scoped access token', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
@@ -983,7 +984,46 @@ describe('resolvePrivateIdentity', () => {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a scoped access token when the host has no app', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts a full-access token, which holds the account', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer,
|
||||
fullAccess: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { AppStore } from '../../../stores/app/AppStore.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -460,11 +460,15 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*
|
||||
* A scoped access token (a read URL, whoever issued it) is never the account
|
||||
* here: the hosted cookie minted from it would outlive the token's revocation.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
|
||||
if (!expectedAppUid) return true;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
|
||||
@@ -102,6 +102,8 @@ Entries the other app wrote with `disableSharing` are never delivered. Marking a
|
||||
|
||||
## Sharing key-value data with another user
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
`kv:` always means your own data. When another user shares part of theirs with you using [`puter.kv.share()`](/KV/share/), you watch it through the share's **handle**, which goes where an app id would go. For a walkthrough, see [Send Live Updates to Other Users](/recipes/send-live-updates-to-other-users/).
|
||||
|
||||
```js
|
||||
|
||||
@@ -232,6 +232,8 @@ These Key-Value Store features are supported out of the box when using Puter.js:
|
||||
|
||||
## Sharing with other users
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A user can share the keys under a prefix with another Puter user. The user who shares is the share's owner, and the user it's shared with is its holder. The keys stay in the owner's store.
|
||||
|
||||
Pick the tool by what you want to do:
|
||||
|
||||
@@ -38,6 +38,8 @@ A plain object is read as a path map, never appended as an element: to append an
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -36,6 +36,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -18,6 +18,8 @@ A string containing the name of the key you want to remove.
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -25,6 +25,8 @@ The number of seconds until the key is removed from the key-value store. `0` or
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -25,6 +25,8 @@ The Unix timestamp (in seconds) at which the key will be removed from the key-va
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -17,6 +17,8 @@ None
|
||||
## Return value
|
||||
A `Promise` that will resolve to `true` when the key-value store has been flushed (emptied), or reject with an error on failure.
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Flushing works only on your own store. Passing the `handle` of data another user shared with you is rejected with `kv_handle_option_invalid`. To clear a shared prefix, delete its keys with [`puter.kv.del()`](/KV/del/).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -18,6 +18,8 @@ A string containing the name of the key you want to retrieve the value of.
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `read` or `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -36,6 +36,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -35,6 +35,8 @@ If set to `true`, the returned array will contain objects with both `key` and `v
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument of the positional forms. It takes these properties:
|
||||
|
||||
- `handle` (String): List data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The share needs `read` or `write` access. The `pattern` is relative to the shared prefix and can be at most 768 bytes, and the returned keys are relative to the prefix too. The results and `total` leave out private entries. Every page of a full listing or a stream is fetched through the handle. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -4,6 +4,8 @@ description: List the key-value data other users have shared with you.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Lists the live shares other users made with you, a page at a time. Each share comes with the `handle` you pass to the other [`puter.kv`](/KV/) methods to use it.
|
||||
|
||||
A share tells you its level, its app, and the owner's label. It never says who shared it or what the prefix is. To tell shares apart, read the label, or read a key the owner keeps for that, such as `meta`, through the handle. Reading needs `read` or `write` access, so a `watch` share only has its label.
|
||||
|
||||
@@ -4,6 +4,8 @@ description: List the key-value shares you made with other users, including ones
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Lists the shares you made with [`puter.kv.share()`](/KV/share/), a page at a time. Shares that stopped stay in the listing with `revokedAt` and `revokedBy` set, as a record of who had access and when it ended.
|
||||
|
||||
In an app, the listing has only the shares of that app's data. Outside an app, it has every share you made.
|
||||
|
||||
@@ -27,6 +27,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -56,6 +56,8 @@ The batch form rejects it with `invalid_request`.
|
||||
|
||||
#### `handle` (String) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Pass inside the trailing options object, as in `set(key, value, { handle })`, to write into data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix, and the share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users). The batch form takes it too. With `handle`, a key can be at most 768 bytes.
|
||||
|
||||
#### `items` (Array) (batch only)
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Share the keys under a prefix with another Puter user, so they can
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Shares the keys under a prefix with another Puter user. The other user becomes the share's holder, and you stay its owner. The keys stay in your store, and the holder reaches them through the share.
|
||||
|
||||
Each share has a `handle`. The holder finds it with [`puter.kv.listShared()`](/KV/listShared/) and passes it as `handle` to the other [`puter.kv`](/KV/) methods. [Sharing with other users](/KV/#sharing-with-other-users) shows how a holder uses a share.
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Stop sharing key-value data with another user, or remove your own a
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Stops a share made with [`puter.kv.share()`](/KV/share/). Either side can call it:
|
||||
|
||||
- **The owner** revokes the share. The holder loses access, and any narrower share you made with the same holder inside the same prefix stops too.
|
||||
|
||||
@@ -35,6 +35,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -40,6 +40,8 @@ What the request is about. The resource decides which details are accepted and w
|
||||
| `'backgroundEvents'` | `Resource.BACKGROUND_EVENTS` | None | `true` if granted |
|
||||
| `'kvShare'` | `Resource.KV_SHARE` | `{ prefix, access }` | `true` if granted |
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
#### `details` (object) (optional)
|
||||
|
||||
The fields the resource takes:
|
||||
|
||||
@@ -186,6 +186,8 @@ Before the provider runs, the balance must cover every page the call can be bill
|
||||
|
||||
### Key-value store
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
| Limit | Paid | Free | Anonymous |
|
||||
| ------------------------------- | ---- | ---- | --------- |
|
||||
| `get` / `set` / etc. per 10 s | 400 | 400 | 200 |
|
||||
@@ -422,6 +424,8 @@ See [Events](/Events/) for an overview, [`onLocal()`](/Events/subjects/#gaps) fo
|
||||
|
||||
#### What an account can hold
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
| Limit | Scope | Paid | Free | Anonymous |
|
||||
| ------------------------------------ | ------------------ | ---- | ---- | --------- |
|
||||
| Persistent subscriptions | Per user, all apps | 500 | 100 | 0 |
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, events, perms]
|
||||
order: 22
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A group chat usually needs a server that stores the messages and sends each new
|
||||
one to everyone in the room. With [`puter.kv.share()`](/KV/share/), the room can
|
||||
live in the Puter account of the user who creates it instead. Each member gets
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 18
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A fitness app keeps each user's workouts in their own Puter account. A user who
|
||||
trains with a coach wants the coach to see the log, but not change it. With
|
||||
[`puter.kv.share()`](/KV/share/) and read access, the coach can read and list
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 21
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Once users share data in your app, they need a way to see who has it and to
|
||||
take it back. This recipe builds two screens, a "People with access" panel for
|
||||
the data a user shared and a "Shared with me" list for the data other people
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, events]
|
||||
order: 19
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A scoreboard app keeps the score of a game in the scorekeeper's Puter account.
|
||||
Fans want to see each change as it happens. With a watch share, another Puter
|
||||
user gets an [event](/Events/) each time a key under a prefix changes, with the
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 17
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
[`puter.kv`](/KV/) keeps each user's data in their own Puter account, where only
|
||||
they can reach it. A shared grocery list or to-do list needs two people working
|
||||
on the same data. With [`puter.kv.share()`](/KV/share/), a user can give another
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms, workers]
|
||||
order: 23
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Some apps share data without the user clicking a share button. A team chat app
|
||||
can share each new room with the whole team as soon as it's created, or a
|
||||
backend can add a member to a room when your own rules say so. In both cases your app calls
|
||||
|
||||
@@ -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