Compare commits

..
Author SHA1 Message Date
jelveh 8462d30048 fix(hosting): disconnect the directory when update gets a null root_dir
puter.hosting.update(name, null) sends root_dir: null, which the driver
stringified into a lookup for a folder named "null" and rejected, so
disassociating a website from a folder's properties always failed.
2026-10-25 23:26:17 -07:00
Nariman Jelveh 21846192d1 feat(dashboard): publish websites and workers from Files in dashboard dialogs (#4342)
* feat(dashboard): publish websites and workers from Files in dashboard dialogs

The Files tab's Publish as website and Publish as Worker actions open
dashboard form dialogs instead of desktop windows: the address field shows
the domain inline and shapes the typed name into a valid one, workers get a
Sandboxed switch, and success shows the live address with copy and open.
Published rows get their badges right away.

The form dialog gains suffix and switch fields and a shared copy row, which
the API token dialog now uses too. The save-account / confirm-email gate
before publishing moves to one helper shared by both flows.

* fix(gui): ask a just-saved account to confirm its email before publishing

Saving a temporary account can leave its email unconfirmed when signup
doesn't require confirmation. The publish gate returned right after the
save, skipping the confirm-email step the old inline checks still ran.

* fix(dashboard): link a published site to the address the server reports

The success screen built https://<name>.<hosting domain>, which is wrong on
a self-hosted instance serving sites over http. Use the address the create
response carries, the one the rest of the GUI shows, and fall back to the
built one only when it's missing.

* fix(dashboard): dim a suffixed field as one box while the form submits

Only the input inside it dimmed, leaving the domain after the name at full
contrast while the field was locked.

* fix(dashboard): point a taken or reserved publish name at the address field

The server's reason showed in the status line, but the field stayed
unmarked and focus stayed on Publish, so changing the name took an extra
click. Mark the field invalid and focus it for the rejections that mean
"pick another name".

* fix(dashboard): type into a suffixed field when its suffix is pressed

The domain after the name is drawn inside the field's box but was a plain
span, so pressing it focused nothing. A press there now focuses the field
with the caret at the end, and the suffix shows a text cursor.

* chore(dashboard): drop a phone width rule the icon buttons never used

The form dialog's phone flex rule sizes these buttons, so the width never
applied; touch screens get 44px from the coarse-pointer min-width.

* fix(dashboard): read an underscore in a website address as a word break

A website name can't hold an underscore, so typing My_Cool Site gave
mycool-site. Underscores now become a hyphen, as spaces do; a worker
name keeps them.

* fix(dashboard): keep the letter when an accented one is typed into a publish address

Café became caf: an accented letter isn't a DNS character, so it was
dropped whole. The accent comes off and the letter stays.

* fix(dashboard): cap a website address at a hostname label's 63 characters

The server takes a 64-character subdomain, but DNS caps a label at 63,
so the site it publishes can't be reached. The field now stops at 63,
as the worker one does.

* fix(dashboard): let a publish address finish composing before shaping it

The address was reshaped on every input, composing included: a dead
key's accent turned into a hyphen before its letter arrived, and IME
text was rewritten under the composition. Composed text is now shaped
once it's in.
2026-10-25 21:34:39 -07:00
Nariman JelvehandClaude Opus 5.5 b9f0c8601d fix(dashboard): eighth adversarial review of Files — bugs, UX, a11y, perf (#4330)
* fix(dashboard): don't preview a file named after an image type, or a failed image

Space on a file named "png" (no extension) opened the image preview over a
broken image: the type was read from the last dot-separated part of the
name, which for an extensionless name is the whole name. It's now read from
the extension.

A preview whose image URL couldn't be fetched left the previous image up
under the newly selected row and raised an unhandled rejection. It now
closes instead.

* fix(dashboard): sort Files names by number, and break size/date ties by name

Names were compared as plain text, so "file10" sorted before "file2" and
"dir10" before "dir2". They now compare as a file manager does, with runs
of digits by value.

Sorting by size or date left rows with the same value (every folder, when
sorting by size) in whatever order the server listed them, and a row added
afterwards (a new folder, a rename) landed after its equals instead of where
a fresh listing would put it. Ties now fall back to the name, in the full
sort and in the single-row insert alike.

The sort also parsed each trashed item's metadata on every comparison; it
now reads each entry's sort key once.

* fix(dashboard): ask about "this item" when permanently deleting one Trash item

The Delete key and the phone selection bar's Delete Permanently asked
"Are you sure you want to permanently delete these items?" for a single
item too. One item now gets the single-item question, as from its own
menu. The three copies of the confirm-and-delete step (Delete key,
selection bar, multi-select menu) are now one.

* fix(dashboard): don't let saved column widths squeeze out an auto-width name

Saved widths too wide for the listing fall back to the defaults, but only
when the Name column had a saved width too. Beside an auto-width name, a
Size or Modified column widened on a large screen kept its width on a
smaller one and squeezed the names to nothing, with the row scrolling
sideways. The check now counts an auto-width name at its 100px minimum.

* fix(dashboard): give Files names a phone's full row, not a desktop column width

A narrow listing drops the columns for two-row rows, where each name has
the row's width. Names were still cut to the Name column's saved width
whenever the saved columns happened to fit, so a 120px column set on a
desktop cut names to about 100px on a phone with 280px to spare.

* fix(fs): refuse to replace a folder that contains the item being moved or copied

Moving or copying an item onto a name taken by one of its own parent
folders, with overwrite, removed that folder recursively first, and the
item went with it. Cutting the inner "photos" out of photos/photos, pasting
it next to the outer one and choosing Replace deleted both folders and
everything in them. Such an overwrite is now refused before anything is
removed.

* fix(dashboard): keep the Files selection when an upload lands in another folder

An upload into a folder other than the one on screen (a drop on a folder
row, a sidebar folder or a crumb, or Upload from a folder's menu) still
listed the folder on screen again when it finished. That cleared whatever
was selected there and left an open menu acting on rows no longer on the
page. Only an upload into the folder on screen refreshes it now.

The Upload picker ran its own copy of the upload flow; it now goes through
the same one as drops.

* fix(dashboard): keep Files rows up while a refresh reads web link and app icons

A refresh of the folder on screen keeps its rows until the new listing is
in, so it doesn't flash blank. But it cleared them before reading the
icons, and a web link's or .app's icon is read from the file over the
network, so in such a folder every re-sort, upload or return to the tab
blanked the pane for those reads. The icons are now read before the rows
are swapped.

* fix(dashboard): Ctrl/Cmd-drag a Files selection as a whole

Pressing Ctrl (Cmd on a Mac) on a row that was already selected took it out
of the selection at once, so a drag starting there collapsed the selection
to that one row: a Ctrl-drag of three files to copy them copied one, under
a ghost that still read "3". The row now leaves the selection when the
button is released without a drag, as in a desktop file manager, and a
drag carries the whole selection.

* fix(dashboard): let Enter open the row the arrow keys moved to in Files

Clicking a sidebar folder, crumb or toolbar button leaves it focused, and
the arrow keys and type-to-select still move the row selection from there.
Enter, Space and the menu key then went to that focused control: after
clicking Documents and pressing Down twice, Enter opened Documents again
and dropped the selection instead of opening the highlighted row. The
listing now takes the focus when these keys move the selection, so the
keys after them act on the rows, and Shift+Tab goes back to the toolbar.

* fix(dashboard): let Files' image preview be cancelled while it loads, and close it with the tab

The preview only counted as open once its image URL had come back (two
round-trips), so nothing could cancel it before then: Escape cleared the
selection and the image popped up anyway, a second Space opened it instead
of closing it, and opening a folder left the old image to appear over the
new one. It now counts as open from the start, so each of those cancels
it.

Browser Back/Forward to another dashboard tab left the preview floating
over that tab, where Escape and Space no longer reach it. A route change
away from Files now closes it, as it does Share and Properties.

* fix(dashboard): keep Files keys off a new folder until the server has made it

A new folder's row goes up with the name editor open before mkdir answers.
Clicks and menus already wait on it, but the keyboard didn't: pressing
Escape and then Delete on a slow connection sent a move for the row's
placeholder and showed "Moving New Folder: Subject does not exist", and
Enter opened the predicted path before it existed. Enter, Delete, Ctrl/Cmd+C
and Ctrl/Cmd+X now skip such a row, as do Cut and Delete in the
multi-select menu and the phone's selection bar.

* fix(dashboard): explain a Files action that fails on the network

A request that fails at the network level is rejected with no message.
New Folder then took its row back with no alert at all, and Restore and
the New menu's files showed "[object XMLHttpRequest]". A failed move from
the clipboard named the item and nothing else. Each now falls back to
"An unknown error occurred."

* fix(dashboard): don't offer Trash folders, or view-only shares' crumbs, as Files drop targets

Inside the Trash a folder row lit up as a drop target and then refused the
drop with an untranslated "Cannot move items into a deleted folder.", and a
crumb inside the Trash took the move, though only Restore may take an item
out of the Trash. A crumb of a folder shared with the user to view only
lit up too, and the drop ended in one refusal per item from the server.

Such folders no longer light up, and a drop on one goes back, as a drop on
a view-only folder row already did. A view-only crumb still opens on a
dwell.

* fix(dashboard): count a new folder, not the old selection, in the Files footer

New Folder selects the new row, but the footer was updated before that and
kept describing what was selected before ("3 items selected"), as did the
phone's selection bar, whose buttons by then acted on the new folder. A
Shift+click also still extended from the old anchor. The footer now
follows the new selection, and the new row is the anchor.

* fix(dashboard): update the Files footer when Enter ends a rename

Ending a rename with Enter selects the row, and then called the desktop
Explorer's footer update, which finds no footer in the dashboard. Renaming
a row from its menu while another was selected left the footer at
"1 item selected" with two rows selected.

* fix(dashboard): stop Files' image preview from running out of read URLs

The preview fetched a read URL for every image it showed, and read URLs
share the access-token allowance of 20 an hour. Stepping through a folder
of images with the arrow keys spent it in seconds, after which the preview
(and any app asking for a read URL) failed for the rest of the hour. The
preview now reads the image directly and shows it from memory, freed when
it closes or moves on, and a held arrow key loads only the image it stops
on.

* fix(dashboard): don't offer to publish a folder the user may not share from Files' folder menus

The background, crumb and sidebar menus offered Publish as Website for any
folder, so in a folder shared with the user to view or edit it opened the
publish dialog only for the server to refuse. Publishing takes the right
sharing does; the item now stays disabled without it, as on a folder row's
own menu.

* fix(dashboard): open a Files folder under its new name when it's opened mid-rename

Double-clicking a folder right after typing its new name (the first click
sends the rename) opened it under the old path, which the rename then
took away: the view sat in a folder that no longer existed, with crumbs
and Back/Forward disagreeing about its name, and New Folder or Upload
there failed. Opening a row (double-click, tap, Enter, or Open from its
menu) now waits for a rename still on its way, and goes by one path.

* fix(dashboard): keep Files' Back/Forward in step with folders moved or trashed

Back and Forward kept a folder's old path after it was moved or deleted,
here or from elsewhere: going into a folder, back out, and dragging it
somewhere else (or deleting it) left Forward leading to "This folder
couldn't be opened", with New Folder and Upload gone. History entries now
follow a moved folder and what is inside it, and a trashed or deleted
one's entries are dropped.

* fix(dashboard): keep screen readers from reading Files' icon file names

The crumbs' separators, the folder sidebar's icons and every row's icon
were images with no alt text, so a screen reader announced each one, by
file name in some ("triangle-right.svg"), between every crumb and before
every name. They are decorative beside the text they sit next to and are
now marked so. The crumb of the folder on screen is marked as the current
page.

* fix(dashboard): make double-click on a Files column divider fit the column

Double-clicking a column divider measured the text in system-ui, but the
listing renders in Inter, and took the cells' padding as 8px a side where
Size and Modified have 10. Right after "fit to content", the longest name
was still cut in the middle and every Modified cell ended in an ellipsis
("40 minutes a…"). Each column is now measured in its cells' own font and
padding.

* fix(dashboard): keep a renamed item among its sharer's in Files → Shared

With Shared sorted by "Shared by", renaming an item shared with the user
to edit placed its row by name alone, so it could land among another
person's items until the listing was loaded again. A row put back in
place now goes by its sharer first there, as the listing does.

* fix(dashboard): keep every item, and empty folders' places, in a downloaded zip

Downloading several items as one zip keyed each by its name, so two items
with the same name (in Files → Shared, two people can each share a
"Notes.txt") came out as one file, the other silently gone. Such names
now get " (1)" and so on in the zip, as a folder does for a duplicate.

Empty folders were put at the top of the zip by their own name, not where
they sat: zipping a folder holding x/y gave "y/" next to "x/". They now
keep their place.

dedupedName moves to helpers/, now that the zip uses it as well.

* fix(dashboard): give Files grid tiles both lines of their name

A tile has room for two lines of name, but names were cut to fit one:
"Quarterly financial report final version.pdf" showed as
"Quarterl…l version.pdf" over an empty second line. A name now shows whole
when it wraps into the two lines, and is cut in the middle, keeping its
extension, only as far as it has to be to fit them.

Where the browser wraps a name can't be told from text widths alone, so
the tiles are written and read back together, one layout a round rather
than one a tile.

* fix(dashboard): keep the Files listing when a refresh can't reach the server

A refresh of the folder on screen that failed for any reason replaced the
listing with "This folder couldn't be opened." and took New Folder and
Upload away. Returning to the tab triggers one, so opening a laptop before
its Wi-Fi reconnected, or a moment's lost connection during a re-sort, left
a folder that was fine looking broken until it was opened again. A refresh
that doesn't reach the server now leaves the rows as they are; one the
server answers with an error (the folder is gone) still says so.

* fix(dashboard): don't drop files added to a Files folder while a new folder is made

While New Folder (or New › file) waited for the server, every item added
to the folder on screen was dropped, not only the one being made, so a
file uploaded from another tab or device in that moment didn't show up
until the folder was listed again. Such items are now held until the
create is done and added then; the create's own comes through as an
update of the row already drawn for it.

* perf(dashboard): refresh Files on return to the browser tab only when Files is showing

Coming back to the browser tab listed the Files folder again even with
another dashboard tab in front, and in Files → Shared that meant every
share all over again. The refresh now waits until Files is next shown.

* fix(dashboard): move the keyboard focus off a Files arrow that disables itself

Pressing Enter on Back to reach the first folder disables Back while it has
the focus, and a disabled arrow is drawn at 10% opacity, focus ring
included, so the keyboard user lost track of where they were. The focus
now moves on to an arrow that still works, or to the listing.

* fix(dashboard): show "can't drop here" when dragging files from the computer where Files takes none

Files dragged in from the computer showed the copy pointer everywhere in
Files, then dropping on the Trash, in Shared, on a folder shared to view
only, or while a folder was loading did nothing. The pointer now shows the
drop isn't possible there.

* fix(dashboard): let Files' type-to-select find names with "i" in a Turkish browser

Typed letters were lower-cased for the browser's language and the names
they're matched against without one. In Turkish, "I" lower-cases to "ı",
so typing it never reached "Invoices". Both are now lower-cased alike.

* fix(dashboard): bring Files' Back/Forward along when a folder rename is undone

Renaming a folder points Back and Forward at its new name, but undoing the
rename left them there: Forward then led to "This folder couldn't be
opened" for a name the folder no longer had. Undo now takes them back to
the old name too.

* fix(dashboard): don't shrink Files → Shared's name column when it's dragged wider

In a narrow Shared listing, "Shared by" is laid out narrower beside a name
column with no set width. Dragging the name's divider gave it a width, at
which "Shared by" sprang back to its own and pushed the name narrower than
it started: dragging right made it 30px smaller, and there it stuck.
Sizing the name now keeps "Shared by" at the width it had on screen.

* docs(fs): note that move and copy never overwrite a folder holding the source

* fix(dashboard): keep the Files drop zone after files dragged in cross a folder row

Leaving a folder row while dragging files in from the computer also took
the highlight off the listing around it, as the row's notice bubbled up to
the listing's own. With the "can't drop here" pointer going by that
highlight, a drop on the listing after crossing a folder row was refused.
The row's notices stay with the row, and the listing goes by whether the
folder takes uploads.

* fix(dashboard): don't let a Files folder wait for ever on a rename to open

Opening a row waits for a rename on its way, which settles in the last
step of the rename's handling. A failure after the rename itself (such as
refreshing the folder's websites) never got there, and the folder then
wouldn't open until it was listed again. Opening waits five seconds at
most.

* docs(fs): drop the note on overwriting a folder that holds the source

The refusal is an edge case, and its error message already explains it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-25 18:59:24 -07:00
32 changed files with 2219 additions and 1508 deletions

No files matched your search

@@ -573,6 +573,24 @@ describe('SubdomainDriver.update field handling', () => {
expect(cleared.associated_app).toBeNull();
});
it('disconnects the directory when root_dir is null', async () => {
const { actor } = await makeUser();
const name = uniqueName('unlink');
await createSubdomain(actor, name);
// The shape `puter.hosting.update(name, null)` sends.
const updated = (await withActor(actor, () =>
driver.update({
id: { subdomain: name },
object: { root_dir: null },
}),
)) as Record<string, unknown>;
expect(updated.root_dir).toBeNull();
const row = await server.stores.subdomain.getBySubdomain(name);
expect(row?.root_dir_id ?? null).toBeNull();
});
it('rejects repointing root_dir at a path that does not exist', async () => {
const { actor } = await makeUser();
const created = await createSubdomain(actor, uniqueName('badroot'));
@@ -357,7 +357,10 @@ export class SubdomainDriver extends PuterDriver {
// Subdomain name is immutable — strip if provided
const patch: Record<string, unknown> = {};
if (object.root_dir !== undefined) {
// A null root_dir disconnects the directory; the site stays reserved.
if (object.root_dir === null) {
patch.root_dir_id = null;
} else if (object.root_dir !== undefined) {
const rootDirPath = expandTildePath(
String(object.root_dir),
actor.user.username,
+38
View File
@@ -4749,6 +4749,25 @@ describe('FSService move', () => {
expect(await readBack(overwritten)).toBe('winner');
});
it('refuses to overwrite a directory the moved entry sits inside', async () => {
const outer = await fs.mkdir(user.userId, {
path: `${user.home}/Documents/nest/photos/photos`,
createMissingParents: true,
});
await writeFile(user, `${user.home}/Documents/nest/photos/photos/a.txt`, 'keep');
const nest = (await entryAt(user, '/Documents/nest'))!;
const refused = await caught(() =>
fs.move(user.userId, {
source: outer,
destinationParent: nest,
overwrite: true,
}),
);
expect(refused.statusCode).toBe(400);
expect(await entryAt(user, '/Documents/nest/photos/photos/a.txt')).toBeTruthy();
});
/**
* Reproduce an occupant arriving after the collision probe: the probe
* misses it (a concurrent move, or a replica that hasn't caught up) and the
@@ -5038,6 +5057,25 @@ describe('FSService copy', () => {
).toBe('cannot_copy_directory_into_itself');
});
it('refuses to overwrite a directory the copied entry sits inside', async () => {
const inner = await fs.mkdir(user.userId, {
path: `${user.home}/Documents/cp-nest/photos/photos`,
createMissingParents: true,
});
await writeFile(user, `${user.home}/Documents/cp-nest/photos/photos/a.txt`, 'keep');
const nest = (await entryAt(user, '/Documents/cp-nest'))!;
const refused = await caught(() =>
fs.copy(user.userId, {
source: inner,
destinationParent: nest,
overwrite: true,
}),
);
expect(refused.statusCode).toBe(400);
expect(await entryAt(user, '/Documents/cp-nest/photos/photos/a.txt')).toBeTruthy();
});
it('handles a destination collision by conflict, overwrite or dedupe', async () => {
const destination = (await entryAt(user, '/Desktop'))!;
const source = await writeFile(
+30 -12
View File
@@ -3673,18 +3673,17 @@ export class FSService extends PuterService {
updates.filter(isValid),
);
let next = 0;
return updates.map(
(update): PromiseSettledResult<FSEntry> =>
isValid(update)
? stored[next++]
: {
status: 'rejected',
reason: new HttpError(
400,
'Invalid file entry identifier for thumbnail update',
{ legacyCode: 'bad_request' },
),
},
return updates.map((update): PromiseSettledResult<FSEntry> =>
isValid(update)
? stored[next++]
: {
status: 'rejected',
reason: new HttpError(
400,
'Invalid file entry identifier for thumbnail update',
{ legacyCode: 'bad_request' },
),
},
);
}
@@ -4990,6 +4989,7 @@ export class FSService extends PuterService {
if (collision && collision.uuid !== source.uuid) {
name = await this.#resolveMoveCollision(
userId,
source,
collision,
destinationParent,
name,
@@ -5042,6 +5042,7 @@ export class FSService extends PuterService {
if (raced && raced.uuid !== source.uuid) {
name = await this.#resolveMoveCollision(
userId,
source,
raced,
destinationParent,
name,
@@ -5104,6 +5105,7 @@ export class FSService extends PuterService {
*/
async #resolveMoveCollision(
userId: number,
source: FSEntry,
occupant: FSEntry,
destinationParent: FSEntry,
name: string,
@@ -5115,6 +5117,7 @@ export class FSService extends PuterService {
return this.#findDedupedName(destinationParent, name);
}
if (input.overwrite) {
this.#assertNotReplacingAncestor(source, occupant);
await this.remove(userId, { entry: occupant, recursive: true });
return name;
}
@@ -5130,6 +5133,20 @@ export class FSService extends PuterService {
});
}
/**
* Refuse to overwrite a directory the source sits inside: removing it would
* take the source down with it.
*/
#assertNotReplacingAncestor(source: FSEntry, occupant: FSEntry): void {
if (occupant.isDir && source.path.startsWith(`${occupant.path}/`)) {
throw new HttpError(
400,
`Cannot replace \`${occupant.name}\`: it contains the item being moved or copied`,
{ legacyCode: 'bad_request' },
);
}
}
/**
* Copy an entry to a new parent. For directories, walks descendants and
* issues S3 CopyObject + DB inserts. Thumbnail URLs on entries ride along
@@ -5199,6 +5216,7 @@ export class FSService extends PuterService {
if (collision) {
if (input.overwrite) {
this.#assertNotReplacingAncestor(source, collision);
await this.remove(userId, {
entry: collision,
recursive: true,
File diff suppressed because it is too large. Load diff
+527
View File
@@ -24,8 +24,13 @@ 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 () => []) }));
vi.mock('../../helpers/itemIcon.js', async (importOriginal) => {
const actual = await importOriginal();
return { default: vi.fn(actual.default) };
});
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
@@ -37,6 +42,7 @@ window.icons = {};
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
const { default: itemIcon } = await import('../../helpers/itemIcon.js');
const TRASH = '/alice/Trash';
@@ -245,6 +251,35 @@ describe('TabFiles opening a folder that fails to list', () => {
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
expect(TabFiles.renderingDirectory).toBe(false);
});
it('keeps the rows when a refresh of it can\'t reach the server', async () => {
mountListing(['a.txt'], '/alice/Documents');
TabFiles.renderingDirectory = false;
// puter.js rejects with the XMLHttpRequest itself, which has no code.
window.puter.fs.readdir = vi.fn(async () => {
throw { status: 0 };
});
await TabFiles.renderDirectory('/alice/Documents');
expect($('.files .row')).toHaveLength(1);
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
expect(TabFiles.renderingDirectory).toBe(false);
});
it('says so when a refresh finds it gone', async () => {
mountListing(['a.txt'], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
window.puter.fs.readdir = vi.fn(async () => {
throw { code: 'subject_does_not_exist', message: 'Subject does not exist' };
});
await TabFiles.renderDirectory('/alice/Documents');
expect($('.files .row')).toHaveLength(0);
expect($('.files').text()).toContain("couldn't be opened");
});
});
describe('TabFiles opening a folder during a refresh', () => {
@@ -293,6 +328,7 @@ describe('TabFiles New Folder', () => {
});
vi.spyOn(TabFiles, 'insertAtSortedPosition').mockImplementation(() => {});
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
TabFiles.deferredAdds = [];
});
it('is what Undo takes back, not the upload before it', async () => {
@@ -307,6 +343,62 @@ describe('TabFiles New Folder', () => {
expect($(last.data).attr('data-path')).toBe('/alice/Documents/New Folder');
});
it('becomes the selection the footer counts', async () => {
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
$('.files .row').addClass('selected');
TabFiles.updateFooterStats();
TabFiles.renderingDirectory = false;
TabFiles._creatingItem = 0;
await TabFiles.createFolderInstant('/alice/Documents');
expect($('.files .row.selected')).toHaveLength(1);
expect($('.files-footer-selected-items').text()).toMatch(/^1 item selected/);
});
it('lets items added meanwhile in, once it is made', async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles._creatingItem = 0;
let finishMkdir;
window.puter.fs.mkdir = vi.fn(() => new Promise((resolve) => {
finishMkdir = resolve;
}));
const added = vi.fn();
const previous = window.UIDashboardFileItem;
window.UIDashboardFileItem = (file) => {
if ( TabFiles._creatingItem ) TabFiles.deferredAdds.push(file);
else added(file);
};
const creating = TabFiles.createFolderInstant('/alice/Documents');
await vi.waitFor(() => expect(finishMkdir).toBeTypeOf('function'));
window.UIDashboardFileItem({ uid: 'u2', path: '/alice/Documents/from-elsewhere.txt' });
expect(added).not.toHaveBeenCalled();
finishMkdir({ uid: 'f1', name: 'New Folder', path: '/alice/Documents/New Folder', is_dir: true });
await creating;
expect(added).toHaveBeenCalledWith({ uid: 'u2', path: '/alice/Documents/from-elsewhere.txt' });
window.UIDashboardFileItem = previous;
});
it('says so when the network fails, rather than vanishing', async () => {
const { default: UIAlert } = await import('../UIAlert.js');
UIAlert.mockClear();
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles._creatingItem = 0;
// puter.js rejects with the XMLHttpRequest itself, which has no message.
window.puter.fs.mkdir = vi.fn(async () => {
throw { status: 0 };
});
await TabFiles.createFolderInstant('/alice/Documents');
expect($('.files .row')).toHaveLength(0);
expect(UIAlert).toHaveBeenCalledWith('An unknown error occurred.');
});
it('is made, not dropped, while the folder is refreshing', async () => {
mountListing([], '/alice/Documents');
TabFiles.trashedNames ??= new Map();
@@ -453,6 +545,25 @@ describe('TabFiles.updateNavButtonStates', () => {
expect($('.path-btn-forward').attr('tabindex')).toBe('-1');
expect($('.path-btn-forward').attr('aria-disabled')).toBe('true');
});
it('moves the focus off an arrow it disables', () => {
document.body.innerHTML = `
<div class="window">
<img class="path-btn path-btn-back" tabindex="0">
<img class="path-btn path-btn-forward" tabindex="0">
<img class="path-btn path-btn-up" tabindex="0">
</div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentPath = '/alice';
window.dashboard_nav_history = ['/alice', '/alice/Documents'];
// Back was pressed from Documents.
window.dashboard_nav_history_current_position = 0;
$('.path-btn-back')[0].focus();
TabFiles.updateNavButtonStates();
expect(document.activeElement).toBe($('.path-btn-forward')[0]);
});
});
describe('TabFiles cancelling a drag that opened a folder', () => {
@@ -501,6 +612,81 @@ describe('TabFiles image preview', () => {
expect(TabFiles.previewOpen).toBe(false);
expect($('.image-preview-popover')).toHaveLength(0);
});
it('is not offered for a file named after an image type', async () => {
mountListing(['png'], '/alice/Pictures');
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'png', 'data-is_dir': '0' });
window.puter = { fs: { read: vi.fn(async () => new Blob(['x'])) } };
TabFiles.previewOpen = false;
await TabFiles.showImagePreview($('.files .row'));
expect(window.puter.fs.read).not.toHaveBeenCalled();
expect(TabFiles.previewOpen).toBe(false);
});
it('can be cancelled while its image is still loading', async () => {
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
TabFiles.trashedNames ??= new Map();
TabFiles.renderingDirectory = false;
mountListing(['a.png'], '/alice/Pictures');
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'a.png', 'data-is_dir': '0' });
let finishUrl;
window.puter = { fs: {
read: vi.fn(() => new Promise(resolve => {
finishUrl = resolve;
})),
readdir: vi.fn(async () => []),
} };
const preview = TabFiles.showImagePreview($('.files .row'));
expect(TabFiles.previewOpen).toBe(true);
// Opening another folder meanwhile, as a double-click on one would.
await TabFiles.renderDirectory('/alice/Documents');
finishUrl(new Blob(['x']));
await preview;
expect(TabFiles.previewOpen).toBe(false);
expect($('.image-preview-popover')).toHaveLength(0);
});
it('reads the image rather than spending an access token on it, typed so an SVG shows', async () => {
mountListing(['logo.svg'], '/alice/Pictures');
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'logo.svg', 'data-is_dir': '0' });
window.puter = { fs: { read: vi.fn(async () => new Blob(['<svg/>'])), getReadURL: vi.fn() } };
const created = [];
URL.createObjectURL = vi.fn((blob) => {
created.push(blob);
return 'blob:preview';
});
URL.revokeObjectURL = vi.fn();
await TabFiles.showImagePreview($('.files .row'));
expect(window.puter.fs.getReadURL).not.toHaveBeenCalled();
expect(created[0].type).toBe('image/svg+xml');
expect($('.image-preview-popover img').attr('src')).toBe('blob:preview');
TabFiles.closeImagePreview();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:preview');
});
it('closes, rather than showing the last image, when the next one fails to load', async () => {
mountListing(['b.png'], '/alice/Pictures');
$('.files .row').attr({ 'data-uid': 'u2', 'data-name': 'b.png', 'data-is_dir': '0' });
$('body').append('<div class="image-preview-popover" data-uid="u1"></div>');
TabFiles.previewOpen = true;
TabFiles.previewCurrentUid = 'u1';
window.puter = { fs: { read: vi.fn(async () => { throw new Error('gone'); }) } };
vi.spyOn(console, 'error').mockImplementation(() => {});
await TabFiles.showImagePreview($('.files .row'));
expect(TabFiles.previewOpen).toBe(false);
expect($('.image-preview-popover')).toHaveLength(0);
vi.restoreAllMocks();
});
});
describe('TabFiles.acceptsNewItems', () => {
@@ -728,3 +914,344 @@ describe('TabFiles Remove from Shared', () => {
expect(TabFiles.refreshKeepingSelection).not.toHaveBeenCalled();
});
});
describe('TabFiles sorting', () => {
const entries = [
{ name: 'file10.txt', size: 5, modified: 3 },
{ name: 'dir10', is_dir: true, size: 0, modified: 1 },
{ name: 'file2.txt', size: 5, modified: 3 },
{ name: 'Beta', is_dir: true, size: 0, modified: 1 },
{ name: 'dir2', is_dir: true, size: 0, modified: 1 },
{ name: 'big.bin', size: 50, modified: 2 },
];
const sorted = (column, direction) => {
TabFiles.sortColumn = column;
TabFiles.sortDirection = direction;
return TabFiles.sortFiles(entries).map(e => e.name);
};
beforeEach(() => {
TabFiles.currentPath = '/alice/Documents';
window.shared_path = '/alice/Shared';
});
it('orders names with numbers by value, folders first', () => {
expect(sorted('name', 'asc')).toEqual(['Beta', 'dir2', 'dir10', 'big.bin', 'file2.txt', 'file10.txt']);
});
it('breaks ties in size by name', () => {
expect(sorted('size', 'asc')).toEqual(['Beta', 'dir2', 'dir10', 'file2.txt', 'file10.txt', 'big.bin']);
expect(sorted('size', 'desc')).toEqual(['dir10', 'dir2', 'Beta', 'big.bin', 'file10.txt', 'file2.txt']);
});
it('shows trashed items by the name they had', () => {
TabFiles.sortColumn = 'name';
TabFiles.sortDirection = 'asc';
const trashed = [
{ name: 'uid-b', metadata: JSON.stringify({ original_name: 'b.txt' }) },
{ name: 'uid-a', metadata: JSON.stringify({ original_name: 'a.txt' }) },
];
expect(TabFiles.sortFiles(trashed).map(e => e.name)).toEqual(['uid-a', 'uid-b']);
});
it("keeps a renamed row among its sharer's in the Shared view", () => {
window.shared_path = 'puter://shared';
TabFiles.currentPath = 'puter://shared';
TabFiles.sortColumn = 'shared_by';
TabFiles.sortDirection = 'asc';
const row = (name, sharer) => `<div class="item row" data-name="${name}" data-is_dir="0" data-shared_by="${sharer}"></div>`;
document.body.innerHTML = `<div class="window"><div class="files-tab"><div class="files">
${row('beta.txt', 'alice')}${row('zeta.txt', 'alice')}${row('alpha.txt', 'bob')}
</div></div></div>`;
TabFiles.$el_window = $('.window');
// beta.txt renamed to "zzz.txt": still Alice's, so still before Bob's.
const $renamed = $('.files .row').eq(0).attr('data-name', 'zzz.txt');
TabFiles.insertAtSortedPosition($renamed, { name: 'zzz.txt' });
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get()).toEqual(['zeta.txt', 'zzz.txt', 'alpha.txt']);
});
it('puts an added row where a listing would', () => {
TabFiles.sortColumn = 'size';
TabFiles.sortDirection = 'asc';
const listed = TabFiles.sortFiles(entries.filter(e => e.name !== 'file2.txt'));
document.body.innerHTML = `<div class="window"><div class="files-tab"><div class="files">${listed.map(e => (
`<div class="item row" data-name="${e.name}" data-is_dir="${e.is_dir ? 1 : 0}" data-size="${e.size}" data-modified="${e.modified}"></div>`
)).join('')}</div></div></div>`;
TabFiles.$el_window = $('.window');
const $row = $('<div class="item row" data-name="file2.txt" data-is_dir="0"></div>').appendTo('.files');
TabFiles.insertAtSortedPosition($row, { name: 'file2.txt', size: 5, modified: 3 });
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get())
.toEqual(['Beta', 'dir2', 'dir10', 'file2.txt', 'file10.txt', 'big.bin']);
});
});
describe('TabFiles permanent delete', () => {
beforeEach(() => {
window.trash_path = TRASH;
window.delete_item = vi.fn(async () => {});
window.refresh_trash_state = vi.fn(async () => {});
});
it('asks about "this item" for one row and "these items" for several', async () => {
const { default: UIAlert } = await import('../UIAlert.js');
UIAlert.mockClear();
UIAlert.mockResolvedValue('delete');
mountListing(['a', 'b'], TRASH);
const rows = $('.files .row').toArray();
await TabFiles.deleteRowsPermanently([rows[0]]);
expect(UIAlert.mock.calls[0][0].message).toMatch(/this item/);
expect(window.delete_item).toHaveBeenCalledTimes(1);
await TabFiles.deleteRowsPermanently(rows);
expect(UIAlert.mock.calls[1][0].message).toMatch(/these items/);
expect(window.delete_item).toHaveBeenCalledTimes(3);
});
it('deletes nothing when cancelled', async () => {
const { default: UIAlert } = await import('../UIAlert.js');
UIAlert.mockResolvedValue('cancel');
mountListing(['a'], TRASH);
await TabFiles.deleteRowsPermanently($('.files .row').toArray());
expect(window.delete_item).not.toHaveBeenCalled();
});
});
describe('TabFiles column widths that no longer fit', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.shared_path = 'puter://shared';
window.trash_path = TRASH;
mountListing([], '/alice/Documents');
vi.spyOn($.fn, 'width').mockImplementation(function () {
return this.hasClass('files') ? 1200 : 0;
});
vi.spyOn(TabFiles, 'fitItemNames').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
it('lay out at the defaults beside an auto-width name', () => {
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 1800 };
TabFiles.applyColumnWidths();
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 100px 4px 120px 4px 20px');
expect(TabFiles.columnWidths.modified).toBe(1800);
});
it('stay as saved while they fit', () => {
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 900 };
TabFiles.applyColumnWidths();
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 100px 4px 900px 4px 20px');
});
});
describe('TabFiles names in a narrow listing', () => {
it('are not cut to the name column a wider listing was given', () => {
const name = 'Quarterly financial report final version.pdf';
document.body.innerHTML = `
<div class="window"><div class="files-tab">
<div class="header"><div class="columns" style="display: none"></div></div>
<div class="files files-list-view">
<div class="row item" data-name="${name}"><pre class="item-name"></pre></div>
</div>
</div></div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentView = 'list';
TabFiles.appliedColumnWidths = { name: 120, sharedBy: 160, size: 60, modified: 80 };
TabFiles.fitItemNames($('.files .item-name'));
expect($('.files .item-name').text()).toBe(name);
});
});
describe('TabFiles uploads', () => {
beforeEach(async () => {
vi.restoreAllMocks();
window.trash_path = TRASH;
window.active_uploads = {};
window.actions_history = [];
window.show_save_account_notice_if_needed = vi.fn();
const { default: UIWindowProgress } = await import('../UIWindowProgress.js');
UIWindowProgress.mockResolvedValue({ set_status: vi.fn(), set_progress: vi.fn(), close: vi.fn(), show_error: vi.fn() });
window.puter = { fs: { upload: vi.fn(async (items, dest, callbacks) => {
await callbacks.init('op1', { abort: vi.fn() });
await callbacks.success([{ path: `${dest}/new.txt` }]);
}) } };
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
});
afterEach(() => vi.restoreAllMocks());
it('into another folder leave the listing and its selection alone', async () => {
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
$('.files .row').addClass('selected');
TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents/sub');
await vi.waitFor(() => expect(window.actions_history).toHaveLength(1));
expect(TabFiles.renderDirectory).not.toHaveBeenCalled();
expect($('.files .row.selected')).toHaveLength(2);
});
it('into the folder on screen list it again', async () => {
mountListing(['a.txt'], '/alice/Documents');
TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents');
await vi.waitFor(() => expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents', { consistency: 'strong' }));
});
});
describe('TabFiles refreshing a folder whose icons are slow to read', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
});
});
afterEach(() => vi.restoreAllMocks());
it('keeps the rows up until the new ones can be drawn', async () => {
mountListing(['old.txt'], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
window.puter = { fs: { readdir: vi.fn(async () => [
{ uid: 'w', name: 'site.weblink', path: '/alice/Documents/site.weblink', is_dir: false, size: 1, modified: 0 },
]) } };
let finishIcon;
itemIcon.mockImplementationOnce(() => new Promise(resolve => {
finishIcon = resolve;
}));
const refresh = TabFiles.renderDirectory('/alice/Documents');
await vi.waitFor(() => expect(finishIcon).toBeTypeOf('function'));
expect($('.files .row').map((_, r) => r.getAttribute('data-path')).get()).toEqual(['/alice/Documents/old.txt']);
finishIcon({ image: 'icon.svg', type: 'icon' });
await refresh;
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get()).toEqual(['site.weblink']);
});
});
describe('TabFiles rows still waiting on their mkdir', () => {
it('are not moved, cut or trashed', async () => {
window.user = { username: 'alice', directories: {} };
window.trash_path = TRASH;
mountListing(['a.txt', 'New Folder'], '/alice/Documents');
const [file, pending] = $('.files .row').toArray();
pending.setAttribute('data-pending', '1');
expect(await TabFiles.restructurableRows([file, pending])).toEqual([file]);
});
});
describe('TabFiles folder menu', () => {
const publishItem = items => items.find(item => item?.html === i18n('publish_as_website'));
it("doesn't offer to publish a folder shared with the user to view", async () => {
const { remember_shared_root } = await import('../../helpers/sharedAccess.js');
window.user = { username: 'alice', directories: {} };
window.trash_path = TRASH;
window.shared_path = 'puter://shared';
TabFiles.trashedNames ??= new Map();
const viewOnly = '/bob/0e98/Photos';
remember_shared_root({ path: viewOnly, mode: 'read', name: 'Photos' });
TabFiles.currentPath = viewOnly;
expect(publishItem(TabFiles.generateFolderContextMenu(viewOnly)).disabled).toBe(true);
});
it('offers to publish your own folder', () => {
window.user = { username: 'alice', directories: {} };
window.trash_path = TRASH;
window.shared_path = 'puter://shared';
TabFiles.trashedNames ??= new Map();
TabFiles.unreadablePath = '/alice/Documents';
expect(publishItem(TabFiles.generateFolderContextMenu('/alice/Documents')).disabled).toBe(false);
});
});
describe('TabFiles opening a folder being renamed', () => {
afterEach(() => vi.restoreAllMocks());
it('opens it under its new name', async () => {
mountListing(['New Folder'], '/alice/Documents');
const row = $('.files .row')[0];
row.setAttribute('data-is_dir', '1');
let finishRename;
row._pendingRename = new Promise((resolve) => {
finishRename = resolve;
});
vi.spyOn(TabFiles, 'pushNavHistory').mockImplementation(() => {});
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
const opening = TabFiles.openRow(row);
row.setAttribute('data-path', '/alice/Documents/Photos');
finishRename();
await opening;
expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents/Photos');
});
it('opens it anyway when the rename never reports back', async () => {
vi.useFakeTimers();
mountListing(['Photos'], '/alice/Documents');
const row = $('.files .row')[0];
row.setAttribute('data-is_dir', '1');
row._pendingRename = new Promise(() => {});
vi.spyOn(TabFiles, 'pushNavHistory').mockImplementation(() => {});
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
const opening = TabFiles.openRow(row);
await vi.advanceTimersByTimeAsync(5000);
await opening;
vi.useRealTimers();
expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents/Photos');
});
});
describe('TabFiles.folderMoved', () => {
beforeEach(() => {
TabFiles.$el_window = $('<div>');
});
it('points Back/Forward at a moved folder', () => {
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice/A/sub'];
window.dashboard_nav_history_current_position = 0;
TabFiles.folderMoved('/alice/A', '/alice/Docs/A');
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/Docs/A', '/alice/Docs/A/sub']);
});
it('forgets a trashed folder, and what was inside it', () => {
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice/A/sub', '/alice/B'];
window.dashboard_nav_history_current_position = 3;
TabFiles.folderMoved('/alice/A', null);
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/B']);
expect(window.dashboard_nav_history_current_position).toBe(1);
});
it('keeps the folder on screen, and folds the same folder twice in a row', () => {
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice', '/alice/A'];
window.dashboard_nav_history_current_position = 2;
TabFiles.folderMoved('/alice/A', null);
expect(window.dashboard_nav_history).toEqual(['/alice']);
expect(window.dashboard_nav_history_current_position).toBe(0);
window.dashboard_nav_history = ['/alice', '/alice/A'];
window.dashboard_nav_history_current_position = 1;
TabFiles.folderMoved('/alice/A', null);
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/A']);
});
});
+2 -33
View File
@@ -26,13 +26,12 @@ import create_access_token from '../../helpers/createAccessToken.js';
import postUserProtected from '../../helpers/postUserProtected.js';
import update_username_in_gui from '../../helpers/updateUsernameInGui.js';
import UIWindowManageSessions from '../UIWindowManageSessions.js';
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
// Keep in sync with ProfileService on the backend.
const DISPLAY_NAME_MAX_LENGTH = 64;
const BIO_MAX_LENGTH = 280;
const TOKEN_LABEL_MAX_LENGTH = 64;
const TOKEN_COPIED_RESET_MS = 2000;
/**
* @typedef {object} AccountDialogOptions
@@ -343,43 +342,13 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
const token = await create_access_token({ label: label.trim(), expiresIn: expiresIn || null });
let h = `<div class="dashboard-token-warning">${i18n('token_shown_once_warning')}</div>`;
h += '<div class="dashboard-token-row">';
h += `<input type="text" class="dashboard-dialog-input dashboard-token-value" readonly value="${html_encode(token)}" aria-label="${i18n('api_token')}" spellcheck="false">`;
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-token-copy">${i18n('copy')}</button>`;
h += '</div>';
h += copyRowHtml({ value: token, label: t('api_token'), copiedLabel: t('token_copied') });
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
// The token is shown only once; only Done, ✕ or Escape put it away.
dialog.finish({ html: h, describe: '.dashboard-token-warning', keepOnBackdrop: true });
},
});
let copied_timer = null;
dialog.$dialog.on('click', '.dashboard-token-copy', function () {
const $btn = $(this);
const $value = dialog.$dialog.find('.dashboard-token-value');
const copied = () => {
$btn.text(t('token_copied'));
clearTimeout(copied_timer);
copied_timer = setTimeout(() => $btn.text(t('copy')), TOKEN_COPIED_RESET_MS);
};
// No clipboard access (an insecure origin has no navigator.clipboard
// at all): select it, try the legacy copy, and leave it selected so
// the user can copy it by hand.
const by_hand = () => {
$value.get(0)?.select();
try {
if ( document.execCommand('copy') ) copied();
} catch { /* stays selected */ }
};
if ( navigator.clipboard?.writeText ) {
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
} else {
by_hand();
}
});
dialog.$dialog.on('focus', '.dashboard-token-value', function () {
this.select();
});
dialog.$dialog.on('click', '.dashboard-token-sessions', () => {
UIWindowManageSessions({
window_options: {
@@ -275,12 +275,12 @@ describe('create API token', () => {
expect(String(fetch.mock.calls[0][0])).toBe('https://api.puter.test/auth/create-access-token');
expect(bodyOf(0)).toMatchObject({ label: 'CLI', expiresIn: '30d' });
expect($('.dashboard-token-value').val()).toBe('tok-abc');
expect($('.dashboard-copy-value').val()).toBe('tok-abc');
$('.dashboard-token-copy').trigger('click');
$('.dashboard-copy-btn').trigger('click');
await settle();
expect(writeText).toHaveBeenCalledWith('tok-abc');
expect($('.dashboard-token-copy').text()).toBe('token_copied');
expect($('.dashboard-copy-btn').text()).toBe('token_copied');
});
it('shows why minting failed', async () => {
+11 -1
View File
@@ -398,6 +398,13 @@ async function UIDashboard (options) {
});
}
// Files' Back/Forward follows a folder moved elsewhere, and forgets
// one moved to the Trash.
if ( resp.is_dir && old_path && resp.path ) {
const trashed = resp.path.startsWith(`${window.trash_path}/`);
window.dashboard_object?.folderMoved?.(old_path, trashed ? null : resp.path);
}
// Create new item at destination if user is viewing that directory
if ( window.UIDashboardFileItem ) {
window.UIDashboardFileItem(resp);
@@ -407,6 +414,7 @@ async function UIDashboard (options) {
window.socket.on('item.removed', async (item) => {
if ( item.original_client_socket_id === window.socket.id ) return;
if ( item.descendants_only ) return;
if ( item.is_dir && item.path ) window.dashboard_object?.folderMoved?.(item.path, null);
// Match by uid when present (O(1) attribute selector; uids are UUIDs,
// safe to interpolate) and fall back to a path scan for minimal
@@ -533,10 +541,12 @@ async function UIDashboard (options) {
notifications.close({ restoreFocus: false });
// What was open over the tab being left goes with it, as on a
// sidebar click: the phone drawer, a menu, and Files' Share and
// Properties, which act on its rows (their changes are already saved).
// Properties, which act on its rows (their changes are already saved),
// and its image preview.
setMobileSidebar(false);
$('.context-menu').remove();
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
if ( tab !== 'files' && TabFiles.previewOpen ) TabFiles.closeImagePreview();
// Switch to correct tab
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
+108 -10
View File
@@ -28,6 +28,8 @@ import { ICONS, SVG } from './UIDashboardDialog.js';
const FORM_ICONS = {
done: SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>'),
copy: SVG('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
copied: SVG('<path d="M20 6L9 17l-5-5"/>'),
show: SVG('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
hide: SVG('<path d="M17.9 17.9A10.4 10.4 0 0 1 12 19c-6.4 0-10-7-10-7a18.5 18.5 0 0 1 5.1-5.9"/><path d="M9.9 5.2A9.1 9.1 0 0 1 12 5c6.4 0 10 7 10 7a18.6 18.6 0 0 1-2.2 3.2"/><path d="M14.1 14.1a3 3 0 1 1-4.2-4.2"/><path d="M2 2l20 20"/>'),
};
@@ -36,8 +38,11 @@ const FORM_ICONS = {
* @typedef {object} FormDialogField
* @property {string} name Key in `values()`.
* @property {string} label HTML the caller has already encoded.
* @property {'text'|'email'|'password'|'textarea'|'select'} [type] Defaults to text.
* @property {'text'|'email'|'password'|'textarea'|'select'|'switch'} [type] Defaults to text.
* @property {string} [value]
* @property {boolean} [checked] A switch's initial state.
* @property {string} [suffix] Plain text shown at the end of a text field, as
* in a domain after the name typed into it.
* @property {string} [placeholder] Already encoded.
* @property {string} [hint] HTML the caller has already encoded, shown under the field.
* @property {boolean} [hidden] Rendered but not shown; see `showField`.
@@ -51,18 +56,21 @@ const FORM_ICONS = {
* @typedef {object} FormDialog
* @property {jQuery} $dialog
* @property {() => void} close
* @property {() => Record<string, string>} values Every field's current value.
* @property {() => Record<string, string|boolean>} values Every field's
* current value; a switch's is a boolean.
* @property {(name: string) => jQuery} field A field's control.
* @property {(name: string, show: boolean) => void} showField
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
* @property {(opts: { message?: string, html?: string, describe?: string, keepOnBackdrop?: boolean }) => void} finish
* Swaps the form for a done state (a plain-text `message`, or the caller's
* encoded `html`) with a single Done button. `describe` selects the part of
* Swaps the form for a done state (a plain-text `message`, then the
* caller's encoded `html`, either or both) with a single Done button. `describe` selects the part of
* `html` the focused Done button reads out. `keepOnBackdrop` stops a stray
* click outside the card from closing it, for content shown only once.
*/
const COPIED_RESET_MS = 2000;
const attrs_html = (attrs) => Object.entries(attrs ?? {})
.filter(([, value]) => value !== undefined && value !== null && value !== false)
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
@@ -71,11 +79,28 @@ const attrs_html = (attrs) => Object.entries(attrs ?? {})
const field_html = (field, id) => {
const control_id = `${id}-${field.name}`;
const hint_id = `${control_id}-hint`;
const suffix_id = `${control_id}-suffix`;
const type = field.type ?? 'text';
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="dashboard-dialog-input dashboard-form-input"`
+ (field.hint ? ` aria-describedby="${hint_id}"` : '')
// A switch is drawn by its slider, not the text-box style.
const control_class = type === 'switch' ? 'dashboard-form-input' : 'dashboard-dialog-input dashboard-form-input';
const described_by = [field.suffix && suffix_id, field.hint && hint_id].filter(Boolean).join(' ');
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="${control_class}"`
+ (described_by ? ` aria-describedby="${described_by}"` : '')
+ attrs_html(field.attrs);
if ( type === 'switch' ) {
return `<div class="dashboard-form-field dashboard-form-switch-field" data-field="${html_encode(field.name)}"${field.hidden ? ' hidden' : ''}>`
+ '<div class="dashboard-form-switch-text">'
+ `<label class="dashboard-form-label" for="${control_id}">${field.label}</label>`
+ (field.hint ? `<p class="dashboard-form-hint" id="${hint_id}">${field.hint}</p>` : '')
+ '</div>'
+ '<label class="dashboard-switch">'
+ `<input type="checkbox" role="switch"${common}${field.checked ? ' checked' : ''}>`
+ '<span class="dashboard-switch-slider"></span>'
+ '</label>'
+ '</div>';
}
let control;
if ( type === 'textarea' ) {
control = `<textarea${common} placeholder="${field.placeholder ?? ''}">${html_encode(field.value ?? '')}</textarea>`;
@@ -85,7 +110,9 @@ const field_html = (field, id) => {
} else {
control = `<input type="${type}"${common} placeholder="${field.placeholder ?? ''}" value="${html_encode(field.value ?? '')}">`;
}
if ( type === 'password' ) {
if ( field.suffix ) {
control = `<div class="dashboard-form-affix">${control}<span class="dashboard-form-suffix" id="${suffix_id}">${html_encode(field.suffix)}</span></div>`;
} else if ( type === 'password' ) {
control = '<div class="dashboard-form-secret">'
+ control
+ `<button type="button" class="dashboard-form-reveal" aria-controls="${control_id}"`
@@ -100,6 +127,29 @@ const field_html = (field, id) => {
+ '</div>';
};
/**
* A read-only value with a Copy button, for a `finish` screen. The dialog
* wires the button.
*
* @param {object} opts
* @param {string} opts.value Plain text.
* @param {string} opts.label Plain text naming the value for screen readers.
* @param {string} [opts.copiedLabel] Plain text the button shows once copied.
* @param {boolean} [opts.compact] Icon buttons, leaving the value the width;
* `after` should hold `dashboard-icon-btn`s too.
* @param {string} [opts.after] HTML the caller has already encoded, after the button.
* @returns {string}
*/
export const copyRowHtml = ({ value, label, copiedLabel, compact = false, after = '' }) =>
`<div class="dashboard-copy-row${compact ? ' dashboard-copy-row-compact' : ''}">`
+ `<input type="text" class="dashboard-dialog-input dashboard-copy-value" readonly value="${html_encode(value)}" aria-label="${html_encode(label)}" spellcheck="false">`
+ `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-copy-btn${compact ? ' dashboard-icon-btn' : ''}"`
+ ` data-copied="${html_encode(copiedLabel ?? i18n('copied', [], false))}"`
+ (compact ? ` aria-label="${i18n('copy')}" title="${i18n('copy')}">${FORM_ICONS.copy}` : `>${i18n('copy')}`)
+ '</button>'
+ after
+ '</div>';
/**
* @param {object} opts
* @param {string} opts.title Plain text.
@@ -164,7 +214,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
values () {
const out = {};
$controls().each(function () {
out[this.name] = String($(this).val() ?? '');
out[this.name] = this.type === 'checkbox' ? this.checked : String($(this).val() ?? '');
});
return out;
},
@@ -196,10 +246,12 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
},
finish ({ message, html, describe, keepOnBackdrop = false }) {
keep_on_backdrop = keepOnBackdrop;
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
const content = (message === undefined ? '' : `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message)}</p></div>`)
+ (html ?? '');
$dialog.attr('aria-describedby', null);
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
const described = ! html || (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
const described = message !== undefined
|| (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
// A status region that arrives with its text isn't announced;
// the focused Done button reads the message out instead.
$dialog.find('.dashboard-dialog-footer').html(
@@ -219,6 +271,52 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
$dialog.on('click', '.dashboard-form-cancel, .dashboard-form-done-btn', () => close());
let copied_timer = null;
$dialog.on('click', '.dashboard-copy-btn', function () {
const $btn = $(this);
const $value = $btn.siblings('.dashboard-copy-value');
const show = (done) => {
const text = done ? $btn.attr('data-copied') : i18n('copy', [], false);
if ( $btn.hasClass('dashboard-icon-btn') ) {
$btn.attr({ 'aria-label': text, title: text }).html(done ? FORM_ICONS.copied : FORM_ICONS.copy);
} else {
$btn.text(text);
}
};
const copied = () => {
show(true);
clearTimeout(copied_timer);
copied_timer = setTimeout(() => show(false), COPIED_RESET_MS);
};
// No clipboard access (an insecure origin has no navigator.clipboard
// at all): select it, try the legacy copy, and leave it selected so
// the user can copy it by hand.
const by_hand = () => {
$value.get(0)?.select();
try {
if ( document.execCommand('copy') ) copied();
} catch { /* stays selected */ }
};
if ( navigator.clipboard?.writeText ) {
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
} else {
by_hand();
}
});
$dialog.on('focus', '.dashboard-copy-value', function () {
this.select();
});
// The suffix is drawn inside the field's box, so a press on it types into
// the field, at the end of the name.
$dialog.on('mousedown', '.dashboard-form-suffix', function (e) {
const input = $(this).siblings('.dashboard-form-input').get(0);
if ( ! input || input.disabled ) return;
e.preventDefault();
input.focus({ preventScroll: true });
input.setSelectionRange(input.value.length, input.value.length);
});
// Keep the field focused (and a phone's keyboard up) while toggling. The
// label names the action, so the button carries no aria-pressed as well.
$dialog.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
@@ -29,7 +29,7 @@ window.html_encode = globalThis.html_encode;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
window.matchMedia = () => ({ matches: false });
const { default: UIDashboardFormDialog } = await import('./UIDashboardFormDialog.js');
const { default: UIDashboardFormDialog, copyRowHtml } = await import('./UIDashboardFormDialog.js');
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
const submit = () => $('.dashboard-form-dialog').trigger('submit');
@@ -207,6 +207,52 @@ describe('UIDashboardFormDialog', () => {
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
});
it('shows a message and the caller\'s html together, with a working copy row', async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
open({ onSubmit: (dialog) => dialog.finish({
message: 'Live',
html: copyRowHtml({ value: 'https://a.example', label: 'Address' }),
}) });
submit();
await tick();
expect($('.dashboard-form-done p').text()).toBe('Live');
expect($(`#${$('.dashboard-form-done-btn').attr('aria-describedby')}`).text()).toBe('Live');
expect($('.dashboard-copy-value').val()).toBe('https://a.example');
$('.dashboard-copy-btn').trigger('click');
await tick();
expect(writeText).toHaveBeenCalledWith('https://a.example');
expect($('.dashboard-copy-btn').text()).toBe('copied');
});
it('draws a suffix inside the field and has it read with the field', () => {
open({ fields: [{ name: 'name', label: 'Address', suffix: '.puter.site <b>' }] });
const $input = $('.dashboard-form-input[name="name"]');
expect($input.closest('.dashboard-form-affix')).toHaveLength(1);
expect($(`#${$input.attr('aria-describedby')}`).text()).toBe('.puter.site <b>');
});
it('types into a suffixed field when its suffix is pressed', () => {
open({ fields: [{ name: 'name', label: 'Address', value: 'my-site', suffix: '.puter.site' }] });
const $input = $('.dashboard-form-input[name="name"]');
$input.get(0).blur();
const down = $.Event('mousedown');
$('.dashboard-form-suffix').trigger(down);
expect(down.isDefaultPrevented()).toBe(true);
expect(document.activeElement).toBe($input.get(0));
expect($input.get(0).selectionStart).toBe('my-site'.length);
});
it('reads a switch back as a boolean', () => {
const dialog = open({ fields: [
{ name: 'on', type: 'switch', label: 'On', checked: true },
{ name: 'off', type: 'switch', label: 'Off' },
] });
expect($('.dashboard-form-input[name="on"]').attr('role')).toBe('switch');
expect(dialog.values()).toEqual({ on: true, off: false });
});
it('toggles a password between hidden and shown', () => {
open();
const $input = $('.dashboard-form-input[name="secret"]');
@@ -1,265 +0,0 @@
/*
* 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();
},
};
}
@@ -1,238 +0,0 @@
// @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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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&amp;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,195 @@
/*
* 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 Files tab's publish flows, a folder as a website and a .js file as a
// worker: dashboard form dialogs rather than desktop windows.
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
import { SVG } from './UIDashboardDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
/**
* @typedef {object} PublishTarget
* @property {string} name Display name of the folder or file.
* @property {string} path
* @property {jQuery} [$container] Where the overlay mounts.
* @property {HTMLElement} [returnFocusTo] Where focus goes on close.
*/
const t = (key) => i18n(key, [], false);
const OPEN_ICON = SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>');
/** Rows showing `path` or anything under it, on the dashboard or the desktop. */
const each_item_at = (path, fn) => $('.item').each(function () {
const item_path = this.dataset.path ?? '';
if ( item_path === path || item_path.startsWith(`${path}/`) ) fn($(this), item_path);
});
const KINDS = {
website: {
title: 'window_title_publish_website',
intro: 'publish_website_intro',
published: 'website_published',
rule: PUBLISH_NAME_RULES.website,
// Rejections that mean "pick another name" (SubdomainDriver).
nameErrors: new Set(['conflict', 'subdomain_reserved']),
suffix: () => `.${window.hosting_domain}`,
requiresVerification: () => !! window.require_email_verification_to_publish_website,
async publish (name, path) {
const site = await puter.hosting.create(name, path);
// The server's address carries the deployment's real scheme (a
// self-hosted instance may serve sites over http).
return site?.root_dir?.subdomains?.find((entry) => entry.subdomain === name)?.address
?? `https://${name}.${window.hosting_domain}`;
},
markPublished (path, url) {
each_item_at(path, ($item, item_path) => {
if ( item_path === path ) {
$item.attr('data-has_website', '1').find('.item-has-website-badge').show();
const fsentry = $item.data('fsentry');
if ( fsentry ) fsentry.has_website = true;
} else {
$item.attr('data-website_url', url + item_path.slice(path.length))
.find('.item-has-website-url-badge').show();
}
});
window.update_sites_cache?.();
},
},
worker: {
title: 'window_title_publish_worker',
intro: 'publish_worker_intro',
published: 'worker_published',
rule: PUBLISH_NAME_RULES.worker,
nameErrors: new Set(['conflict']),
suffix: () => '.puter.work',
requiresVerification: () => true,
extraFields: () => [{
name: 'sandboxed',
type: 'switch',
label: i18n('worker_sandboxed'),
hint: i18n('worker_sandboxed_hint'),
checked: true,
}],
async publish (name, path, { sandboxed }) {
const result = await puter.workers.create(name, path, { sandbox: sandboxed });
return result?.url ?? `https://${name}.puter.work`;
},
markPublished (path, url) {
each_item_at(path, ($item, item_path) => {
if ( item_path !== path ) return;
$item.attr({ 'data-is_worker': '1', 'data-worker_url': url })
.find('.item-is-worker').show();
});
},
},
};
/** puter.js rejects with an Error, or with the API's `{ error: { message } }` / `{ message }`. */
const error_message = (err) => err?.error?.message ?? err?.message ?? t('error_unknown_cause');
/**
* @param {'website'|'worker'} kind_name
* @param {PublishTarget} target
*/
const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
const kind = KINDS[kind_name];
if ( ! await ensure_can_publish(kind.requiresVerification()) ) return;
const fields = [{
name: 'name',
label: i18n('publish_address'),
value: normalizePublishName(window.generate_identifier(), kind.rule),
suffix: kind.suffix(),
attrs: {
maxlength: kind.rule.maxLength,
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
'data-gramm_editor': 'false',
},
}];
fields.push(...(kind.extraFields?.() ?? []));
const dialog = UIDashboardFormDialog({
...opts,
title: t(kind.title),
intro: `<p>${i18n(kind.intro, `<strong>${html_encode(name)}</strong>`, false)}</p>`,
submitLabel: t('publish'),
fields,
async onSubmit () {
const values = dialog.values();
const subdomain = finalizePublishName(values.name, kind.rule);
dialog.field('name').val(subdomain);
if ( ! subdomain ) {
dialog.setStatus(t('publish_name_required'), { field: 'name' });
return;
}
let url;
try {
url = await kind.publish(subdomain, path, values);
} catch ( err ) {
const code = err?.error?.code ?? err?.code;
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
return;
}
kind.markPublished(path, url);
const open_link = `<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${OPEN_ICON}</a>`;
dialog.finish({
message: t(kind.published),
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
});
},
});
// Shape the name as it's typed, keeping the caret where it was.
const el_name = dialog.field('name').get(0);
if ( document.activeElement === el_name ) el_name.select();
const shape = () => {
const next = normalizePublishName(el_name.value, kind.rule);
if ( next === el_name.value ) return;
const caret = normalizePublishName(el_name.value.slice(0, el_name.selectionStart ?? el_name.value.length), kind.rule).length;
el_name.value = next;
el_name.setSelectionRange(caret, caret);
};
// Rewriting the text mid-composition (an IME, or a dead key's accent)
// breaks it; the composed text is shaped once it's in.
$(el_name).on('input', (e) => {
if ( ! e.originalEvent?.isComposing ) shape();
}).on('compositionend', shape);
return dialog;
};
/**
* Publishes a folder as a website, once the account may.
*
* @param {PublishTarget} target
*/
export const openPublishWebsiteDialog = (target) => open_publish_dialog('website', target);
/**
* Deploys a .js file as a worker, once the account may.
*
* @param {PublishTarget} target
*/
export const openPublishWorkerDialog = (target) => open_publish_dialog('worker', target);
@@ -0,0 +1,204 @@
// @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 { beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../../helpers/ensureCanPublish.js', () => ({ default: vi.fn(async () => true) }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) || replacements === undefined ? key : `${key}:${replacements}`);
globalThis.html_encode = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
window.html_encode = globalThis.html_encode;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
window.matchMedia = () => ({ matches: false });
const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UIPublishDialogs.js');
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const nameField = () => $('.dashboard-form-input[name="name"]');
const status = () => $('.dashboard-form-status').text();
const submitWith = async (name) => {
if ( name !== undefined ) nameField().val(name);
$('.dashboard-form-dialog').trigger('submit');
for ( let i = 0; i < 5; i++ ) await settle();
};
const addRow = (path) => {
$('body').append(`<div class="item" data-path="${path}" data-has_website="0" data-is_worker="0">`
+ '<img class="item-has-website-badge" style="display:none"><img class="item-has-website-url-badge" style="display:none">'
+ '<img class="item-is-worker" style="display:none"></div>');
return $('.item').last();
};
beforeEach(() => {
document.body.innerHTML = '';
ensure_can_publish.mockClear();
ensure_can_publish.mockResolvedValue(true);
window.hosting_domain = 'puter.site';
window.generate_identifier = () => 'clever-idea-42';
window.update_sites_cache = vi.fn();
window.require_email_verification_to_publish_website = false;
globalThis.puter = {
hosting: { create: vi.fn(async () => ({})) },
workers: { create: vi.fn(async (name) => ({ success: true, url: `https://${name}.puter.work` })) },
};
});
describe('publish website', () => {
it('names the folder and suggests an address on the hosting domain', async () => {
await openPublishWebsiteDialog({ name: 'Site <1>', path: '/alice/Site' });
expect($('.dashboard-dialog-message').html()).toContain('<strong>Site &lt;1&gt;</strong>');
expect(nameField().val()).toBe('clever-idea-42');
expect($('.dashboard-form-suffix').text()).toBe('.puter.site');
});
it('publishes the folder, shows the live address, and badges its rows', async () => {
const $folder = addRow('/alice/Site');
const $child = addRow('/alice/Site/index.html');
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith(' -My Site- ');
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
expect($('.dashboard-form-done p').text()).toBe('website_published');
expect($('.dashboard-copy-value').val()).toBe('https://my-site.puter.site');
expect($('.dashboard-publish-open').attr('href')).toBe('https://my-site.puter.site');
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
$('.dashboard-copy-btn').trigger('click');
await settle();
expect(writeText).toHaveBeenCalledWith('https://my-site.puter.site');
expect($('.dashboard-copy-btn').attr('aria-label')).toBe('copied');
expect($folder.attr('data-has_website')).toBe('1');
expect($folder.find('.item-has-website-badge').css('display')).not.toBe('none');
expect($child.attr('data-website_url')).toBe('https://my-site.puter.site/index.html');
expect(window.update_sites_cache).toHaveBeenCalled();
});
it("links to the server's address for the site when it sends one", async () => {
puter.hosting.create.mockResolvedValueOnce({
subdomain: 'docs',
root_dir: { subdomains: [{ subdomain: 'docs', address: 'http://docs.site.example.test' }] },
});
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('docs');
expect($('.dashboard-copy-value').val()).toBe('http://docs.site.example.test');
expect($('.dashboard-publish-open').attr('href')).toBe('http://docs.site.example.test');
});
it('shapes the address as it is typed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
nameField().val('My_Site 2!').trigger('input');
expect(nameField().val()).toBe('my-site-2');
});
it('leaves text being composed alone and shapes it once composed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
nameField().val('caf\u00B4');
nameField().get(0).dispatchEvent(new InputEvent('input', { bubbles: true, isComposing: true }));
expect(nameField().val()).toBe('caf\u00B4');
nameField().val('café').trigger('compositionend');
expect(nameField().val()).toBe('cafe');
});
it('asks for an address when nothing usable was typed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('---');
expect(status()).toBe('publish_name_required');
expect(nameField().attr('aria-invalid')).toBe('true');
expect(puter.hosting.create).not.toHaveBeenCalled();
});
it('shows why publishing failed and lets the user try again', async () => {
puter.hosting.create.mockRejectedValueOnce({ error: { code: 'internal_error', message: 'Something broke' } });
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('fine');
expect(status()).toBe('Something broke');
expect(nameField().prop('disabled')).toBe(false);
expect(nameField().attr('aria-invalid')).toBeUndefined();
});
it.each([
['conflict', 'A site with this subdomain already exists'],
['subdomain_reserved', "Subdomain 'www' is reserved"],
])('points a %s rejection at the address field', async (code, message) => {
puter.hosting.create.mockRejectedValueOnce({ error: { code, message }, code, message });
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('www');
expect(status()).toBe(message);
expect(nameField().attr('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(nameField().get(0));
});
it('asks for verification only when the deployment needs it', async () => {
window.require_email_verification_to_publish_website = true;
ensure_can_publish.mockResolvedValueOnce(false);
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
expect(ensure_can_publish).toHaveBeenCalledWith(true);
expect($('.dashboard-form-dialog')).toHaveLength(0);
});
});
describe('publish worker', () => {
it('always needs a verified account', async () => {
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
expect(ensure_can_publish).toHaveBeenCalledWith(true);
});
it('deploys sandboxed by default and badges the file', async () => {
const $file = addRow('/alice/api.js');
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
expect($('.dashboard-form-suffix').text()).toBe('.puter.work');
await submitWith('my_api');
expect(puter.workers.create).toHaveBeenCalledWith('my_api', '/alice/api.js', { sandbox: true });
expect($('.dashboard-copy-value').val()).toBe('https://my_api.puter.work');
expect($file.attr('data-is_worker')).toBe('1');
expect($file.attr('data-worker_url')).toBe('https://my_api.puter.work');
});
it('deploys unsandboxed when the switch is off', async () => {
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
$('.dashboard-form-input[name="sandboxed"]').prop('checked', false);
await submitWith('api');
expect(puter.workers.create).toHaveBeenCalledWith('api', '/alice/api.js', { sandbox: false });
});
it('shows a failed build in the status line', async () => {
puter.workers.create.mockRejectedValueOnce(new Error('SyntaxError: Unexpected token'));
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
await submitWith('api');
expect(status()).toBe('SyntaxError: Unexpected token');
expect(nameField().attr('aria-invalid')).toBeUndefined();
});
it('points a taken worker name at the address field', async () => {
puter.workers.create.mockRejectedValueOnce({ error: { code: 'conflict', message: 'Worker name is already in use' } });
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
await submitWith('api');
expect(status()).toBe('Worker name is already in use');
expect(nameField().attr('aria-invalid')).toBe('true');
});
});
@@ -14,9 +14,6 @@
* GNU Affero General Public License for more details.
*/
import { describe, it, expect } from 'vitest';
import { dedupedName } from './dedupedName.js';
import { describe, expect, it } from 'vitest';
import { middleTruncate } from './middleTruncate.js';
+68
View File
@@ -0,0 +1,68 @@
/*
* 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 names a website or worker can be published under, as the backend
// checks them: a website's is a DNS label (SubdomainDriver), and a worker's
// also allows underscores (WorkerDriver).
/**
* @typedef {object} PublishNameRule
* @property {RegExp} separators Runs that become one hyphen, as between words.
* @property {RegExp} invalid Characters the name can't hold.
* @property {number} maxLength
*/
// Either name becomes a hostname label, which DNS caps at 63; the backend
// takes a 64-character website name, but nothing can reach it.
const LABEL_MAX_LENGTH = 63;
/** @type {{ website: PublishNameRule, worker: PublishNameRule }} */
export const PUBLISH_NAME_RULES = {
// A website name can't hold an underscore, so it separates words too.
website: { separators: /[\s_]+/g, invalid: /[^a-z0-9-]/g, maxLength: LABEL_MAX_LENGTH },
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: LABEL_MAX_LENGTH },
};
/**
* What a typed name becomes as the user types: lowercase, accents taken off
* their letters, separator runs turned into a hyphen, and anything else the
* rule refuses dropped.
*
* @param {string} value
* @param {PublishNameRule} rule
* @returns {string}
*/
export const normalizePublishName = (value, rule) => String(value ?? '')
.toLowerCase()
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(rule.separators, '-')
.replace(rule.invalid, '');
/**
* The name to submit: normalized, without the leading or trailing hyphens a
* hostname can't start or end with. Empty when nothing usable is left.
*
* @param {string} value
* @param {PublishNameRule} rule
* @returns {string}
*/
export const finalizePublishName = (value, rule) => normalizePublishName(value, rule)
.slice(0, rule.maxLength)
.replace(/^-+|-+$/g, '');
@@ -0,0 +1,64 @@
/*
* 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 { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
const { website, worker } = PUBLISH_NAME_RULES;
describe('normalizePublishName', () => {
it('lowercases and turns whitespace runs into one hyphen', () => {
expect(normalizePublishName('My Cool Site', website)).toBe('my-cool-site');
});
it('drops characters a website name cannot hold', () => {
expect(normalizePublishName('my site!.v2', website)).toBe('my-sitev2');
});
it('keeps the letter under an accent', () => {
expect(normalizePublishName('Café Ünïcode', website)).toBe('cafe-unicode');
expect(normalizePublishName('Ñandú_API', worker)).toBe('nandu_api');
});
it('turns underscores into hyphens in a website name', () => {
expect(normalizePublishName('My_Cool _Site', website)).toBe('my-cool-site');
});
it('keeps underscores in a worker name', () => {
expect(normalizePublishName('My_API', worker)).toBe('my_api');
});
it('keeps hyphens at the ends while the user is still typing', () => {
expect(normalizePublishName('my-', website)).toBe('my-');
});
});
describe('finalizePublishName', () => {
it('trims hyphens from both ends', () => {
expect(finalizePublishName(' -my site- ', website)).toBe('my-site');
});
it('is empty when nothing usable is left', () => {
expect(finalizePublishName('!!! ', website)).toBe('');
});
it('caps the length at a hostname label\'s 63', () => {
expect(finalizePublishName('a'.repeat(80), website)).toHaveLength(63);
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(63);
});
});
+57
View File
@@ -0,0 +1,57 @@
/*
* 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/>.
*/
/**
* @typedef {Object} SortKey
* @property {string} name - The name the row shows
* @property {number} [size]
* @property {number} [modified]
*/
// One collator for every comparison: localeCompare builds one per call.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
/**
* Orders names as a file manager does: case-insensitively, with runs of
* digits compared by value ("file2" before "file10"). Names equal but for
* case or accents still get a fixed order.
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
export function compareNames (a, b) {
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
}
/**
* Orders two listing entries by `column`, ascending. Equal sizes or dates
* fall back to the name, so their order is the same on every listing.
*
* @param {SortKey} a
* @param {SortKey} b
* @param {'name'|'size'|'modified'} column
* @returns {number}
*/
export function compareEntries (a, b, column) {
let comparison = 0;
if ( column === 'size' ) comparison = (a.size || 0) - (b.size || 0);
else if ( column === 'modified' ) comparison = (a.modified || 0) - (b.modified || 0);
return comparison || compareNames(a.name, b.name);
}
@@ -0,0 +1,62 @@
/*
* 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.
*/
import { describe, it, expect } from 'vitest';
import { compareEntries, compareNames } from './sortEntries.js';
const sortNames = names => [...names].sort(compareNames);
describe('compareNames', () => {
it('orders runs of digits by value', () => {
expect(sortNames(['file10.txt', 'file2.txt', 'file1.txt'])).toEqual(['file1.txt', 'file2.txt', 'file10.txt']);
expect(sortNames(['IMG_100', 'IMG_9', 'IMG_10'])).toEqual(['IMG_9', 'IMG_10', 'IMG_100']);
});
it('ignores case', () => {
expect(sortNames(['beta', 'Alpha', 'gamma'])).toEqual(['Alpha', 'beta', 'gamma']);
});
it('still orders names that differ only in case', () => {
expect(compareNames('a', 'A')).not.toBe(0);
expect(Math.sign(compareNames('a', 'A'))).toBe(-Math.sign(compareNames('A', 'a')));
});
});
describe('compareEntries', () => {
it('breaks a tie in size or date by name', () => {
const entries = [
{ name: 'zeta', size: 0, modified: 5 },
{ name: 'alpha', size: 0, modified: 5 },
{ name: 'dir10', size: 0, modified: 5 },
{ name: 'dir2', size: 0, modified: 5 },
];
const expected = ['alpha', 'dir2', 'dir10', 'zeta'];
expect([...entries].sort((a, b) => compareEntries(a, b, 'size')).map(e => e.name)).toEqual(expected);
expect([...entries].sort((a, b) => compareEntries(a, b, 'modified')).map(e => e.name)).toEqual(expected);
});
it('orders by the column before the name', () => {
const big = { name: 'a', size: 20, modified: 1 };
const small = { name: 'b', size: 10, modified: 2 };
expect(compareEntries(big, small, 'size')).toBeGreaterThan(0);
expect(compareEntries(big, small, 'modified')).toBeLessThan(0);
expect(compareEntries(big, small, 'name')).toBeLessThan(0);
});
it('reads a missing size or date as 0', () => {
expect(compareEntries({ name: 'a' }, { name: 'b', size: 1 }, 'size')).toBeLessThan(0);
});
});
@@ -1,105 +0,0 @@
/*
* 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 };
}
@@ -1,137 +0,0 @@
/*
* 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: '' });
});
});
+1 -8
View File
@@ -20,11 +20,9 @@
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. The dashboard shows a row in its transfer tray instead.
* @param title Shown as the dashboard row's title; the desktop window has none.
* General purpose progress dialog.
* @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.
@@ -33,15 +31,10 @@ import UIDashboardTransfer from './Dashboard/UIDashboardTransfers.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();
+123 -316
View File
@@ -4250,6 +4250,11 @@ body.myapps-reordering .myapps-tile {
outline-offset: -2px;
}
/* Holds the focus while the keys move the selection, which marks the place. */
.dashboard-section-files .files-tab .files:focus {
outline: none;
}
.dashboard-section-files .header .path-btn-disabled {
opacity: 0.1;
pointer-events: none;
@@ -9212,317 +9217,6 @@ 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)
====================================== */
@@ -9590,6 +9284,95 @@ body.dashboard-mode .notifications-close-all {
}
}
/* A text field with fixed text after it (a domain), drawn as one box. */
.dashboard-form-affix {
display: flex;
align-items: center;
height: 38px;
border: 1px solid var(--dashboard-border);
border-radius: 10px;
background: var(--dashboard-input-background);
box-sizing: border-box;
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.dashboard-form-affix:focus-within {
border-color: var(--select-color);
background: var(--dashboard-card-background);
box-shadow: 0 0 0 3px var(--select-ring);
}
.dashboard-form-affix:has([aria-invalid='true']) {
border-color: var(--dashboard-danger-text);
}
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input,
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:focus {
flex: 1 1 auto;
min-width: 0;
height: 100%;
padding: 0 2px 0 12px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
text-align: right;
}
/* Locked while the form submits: dim the whole box, suffix included, as
one field rather than the input alone. */
.dashboard-form-affix:has(:disabled) {
opacity: 0.6;
}
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:disabled {
opacity: 1;
}
.dashboard-form-suffix {
flex-shrink: 0;
max-width: 60%;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--dashboard-text-muted);
cursor: text;
}
@media (pointer: coarse) {
.dashboard-form-affix {
height: 44px;
}
.dashboard-form-suffix {
font-size: 16px;
}
}
/* A switch sits beside its label and hint, as on the settings cards. */
.dashboard-form-switch-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.dashboard-form-switch-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dashboard-form-switch-field .dashboard-form-label {
font-size: 13px;
color: var(--dashboard-text-primary);
cursor: pointer;
}
/* Password fields carry a show/hide toggle inside the box. */
.dashboard-form-secret {
position: relative;
@@ -9649,6 +9432,7 @@ body.dashboard-mode .notifications-close-all {
the content column's gap it would otherwise still claim. */
.dashboard-form-status {
padding: 9px 12px;
white-space: pre-line;
border: 1px solid transparent;
border-radius: 8px;
font-size: 13px;
@@ -9736,12 +9520,13 @@ body.dashboard-mode .notifications-close-all {
line-height: 1.45;
}
.dashboard-token-row {
/* A value to copy: the API token, a published address. */
.dashboard-copy-row {
display: flex;
gap: 8px;
}
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
flex: 1 1 auto;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -9750,15 +9535,37 @@ body.dashboard-mode .notifications-close-all {
/* 16px, like the other dialog inputs on touch, so iOS doesn't zoom on focus. */
@media (pointer: coarse) {
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
font-size: 16px;
}
}
.dashboard-token-copy {
.dashboard-copy-row .dashboard-dialog-btn {
flex-shrink: 0;
}
/* A square button holding only an icon, as in a compact copy row. */
.dashboard-dialog-btn.dashboard-icon-btn {
min-width: 36px;
width: 36px;
padding: 0;
color: var(--dashboard-text-secondary);
text-decoration: none;
}
.dashboard-icon-btn svg {
width: 16px;
height: 16px;
}
@media (pointer: coarse) {
.dashboard-dialog-btn.dashboard-icon-btn {
min-width: 44px;
width: 44px;
padding: 0;
}
}
.dashboard-token-sessions {
display: inline;
padding: 0;
@@ -9782,7 +9589,7 @@ body.dashboard-mode .notifications-close-all {
}
@media (max-width: 600px) {
.dashboard-token-row {
.dashboard-copy-row:not(.dashboard-copy-row-compact) {
flex-direction: column;
}
}
+22 -26
View File
@@ -18,6 +18,7 @@
*/
import { createUploadThumbnailGenerator } from './services/pdfThumbnails/index.js';
import { dedupedName } from './helpers/dedupedName.js';
import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
@@ -1254,10 +1255,7 @@ 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;
@@ -1399,10 +1397,7 @@ 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;
@@ -1753,10 +1748,7 @@ 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;
@@ -1970,6 +1962,12 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
});
}
// The dashboard's Back/Forward follows a moved folder, and
// forgets a trashed one.
if ( $(el_item).attr('data-is_dir') === '1' ) {
window.dashboard_object?.folderMoved?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
}
if ( is_trashing ) {
// if trashing dir...
if ( $(el_item).attr('data-is_dir') === '1' ) {
@@ -2640,7 +2638,10 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
progwin?.set_status(i18n('zip', 'Selection(s)'));
}, 500);
let toBeZipped = {};
const toBeZipped = {};
// Shared items from different people can share a name; each keeps its
// own entry rather than overwriting the other.
const topNames = [];
let perItemAdditionProgress = window.zippingProgressConfig.SEQUENCING / el_items.length;
let currentProgress = 0;
@@ -2648,6 +2649,8 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
const el_item = el_items[idx];
if ( window.operation_cancelled[zip_operation_id] ) return;
let targetPath = $(el_item).attr('data-path');
const topName = dedupedName(path.basename(targetPath), topNames);
topNames.push(topName);
// if directory, zip the directory
if ( $(el_item).attr('data-is_dir') === '1' ) {
@@ -2659,12 +2662,10 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
for ( let cIdx = 0; cIdx < children.length; cIdx++ ) {
const child = children[cIdx];
if ( ! child.relativePath ) {
// Add empty directiories to the zip
toBeZipped = {
...toBeZipped,
[`${path.basename(child.path)}/`]: [await blobToUint8Array(new Blob()), { level: 9 }],
};
if ( child.emptyDirPath !== undefined ) {
// Add empty directories to the zip, where they sit
const dirPath = el_items.length === 1 ? child.emptyDirPath : `${topName}/${child.emptyDirPath}`;
toBeZipped[dirPath || `${topName}/`] = [await blobToUint8Array(new Blob()), { level: 9 }];
} else {
// Add files from directory to the zip
let relativePath;
@@ -2674,17 +2675,14 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
}
else
{
relativePath = `${path.basename(targetPath) }/${ child.relativePath}`;
relativePath = `${topName}/${child.relativePath}`;
}
// read file content
progwin?.set_status(i18n('sequencing', child.relativePath));
let content = await puter.fs.read(child.path);
try {
toBeZipped = {
...toBeZipped,
[relativePath]: [await blobToUint8Array(content), { level: 9 }],
};
toBeZipped[relativePath] = [await blobToUint8Array(content), { level: 9 }];
} catch (e) {
console.error(e);
}
@@ -2697,10 +2695,7 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
else {
progwin?.set_status(i18n('reading', path.basename($(el_items[0]).attr('data-path'))));
let content = await puter.fs.read(targetPath);
toBeZipped = {
...toBeZipped,
[path.basename(targetPath)]: [await blobToUint8Array(content), { level: 9 }],
};
toBeZipped[topName] = [await blobToUint8Array(content), { level: 9 }];
currentProgress += perItemAdditionProgress;
progwin?.set_progress(currentProgress.toPrecision(2));
}
@@ -2771,7 +2766,8 @@ async function readDirectoryRecursive (path, baseDir = '') {
const entries = await puter.fs.readdir(path, { consistency: 'eventual' });
if ( entries.length === 0 ) {
allFiles.push({ path });
// An empty folder, by where it sits in the folder being read.
allFiles.push({ path, emptyDirPath: baseDir });
} else {
// Process each entry
for ( const entry of entries ) {
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../../lib/path.js';
import path from '../lib/path.js';
/** Matches the backend's cap in FSService#findDedupedName. */
const MAX_SUFFIX = 100_000;
+51
View File
@@ -0,0 +1,51 @@
/*
* 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 UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
/**
* Walks a temporary account through saving itself, and an unconfirmed one
* through confirming its email, before it publishes.
*
* @param {boolean} required - Whether this deployment needs a verified email
* for this kind of publish; when false, resolves true straight away.
* @returns {Promise<boolean>} False if the user backed out of either step.
*/
const ensure_can_publish = async function (required) {
if ( ! required ) return true;
if ( window.user.is_temp && ! await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) ) {
return false;
}
// Saving refreshes window.user; a deployment that doesn't confirm at
// signup leaves the new account unconfirmed, and it still has to confirm.
if ( ! window.user.email_confirmed ) {
return !! await UIWindowEmailConfirmationRequired();
}
return true;
};
export default ensure_can_publish;
@@ -0,0 +1,81 @@
// @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 { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
globalThis.i18n = (key) => key;
const { default: ensure_can_publish } = await import('./ensureCanPublish.js');
const { default: UIWindowSaveAccount } = await import('../UI/UIWindowSaveAccount.js');
const { default: UIWindowEmailConfirmationRequired } = await import('../UI/UIWindowEmailConfirmationRequired.js');
beforeEach(() => {
UIWindowSaveAccount.mockReset();
UIWindowEmailConfirmationRequired.mockReset();
});
describe('ensure_can_publish', () => {
it('lets anyone through when verification is not required', async () => {
window.user = { is_temp: true };
expect(await ensure_can_publish(false)).toBe(true);
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
});
it('has a temporary account saved first', async () => {
window.user = { is_temp: true };
UIWindowSaveAccount.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
// Saving replaces window.user with the saved, confirmed account.
UIWindowSaveAccount.mockImplementationOnce(async () => {
window.user = { is_temp: false, email_confirmed: true };
return true;
});
expect(await ensure_can_publish(true)).toBe(true);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
});
it('still has a just-saved account confirm its email when saving left it unconfirmed', async () => {
window.user = { is_temp: true };
UIWindowSaveAccount.mockImplementationOnce(async () => {
window.user = { is_temp: false, email_confirmed: false };
return true;
});
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
});
it('has an unconfirmed email confirmed first', async () => {
window.user = { is_temp: false, email_confirmed: false };
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
});
it('lets a confirmed account through', async () => {
window.user = { is_temp: false, email_confirmed: true };
expect(await ensure_can_publish(true)).toBe(true);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
});
});
+19 -18
View File
@@ -20,11 +20,10 @@
import UIAlert from '../UI/UIAlert.js';
import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js';
import UIWindowShare from '../UI/UIWindowShare.js';
import publish_as_website from './publishAsWebsite.js';
import ensure_can_publish from './ensureCanPublish.js';
import open_item from './openItem.js';
import openWithAI from './openWithAI.js';
import launch_app from './launchApp.js';
@@ -47,6 +46,8 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
* @returns {Promise<Array>} Array of context menu items
@@ -142,11 +143,16 @@ const generate_file_context_menu = async function (options) {
// enforces that). `write` on someone else's folder is not enough.
disabled: !fsentry.is_dir || fsentry.has_website || !may_share,
onClick: async function () {
await publish_as_website({
const target = {
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
});
};
if ( options.onPublishWebsite ) {
options.onPublishWebsite(target);
} else {
await publish_as_website(target);
}
},
});
}
@@ -160,22 +166,17 @@ const generate_file_context_menu = async function (options) {
html: i18n('publish_as_serverless_worker'),
disabled: is_worker,
onClick: async function () {
if ( window.user.is_temp &&
!await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) ) {
const target = {
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
};
if ( options.onPublishWorker ) {
options.onPublishWorker(target);
return;
}
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) {
return;
}
UIWindowPublishWorker(fsentry.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
if ( ! await ensure_can_publish(true) ) return;
UIWindowPublishWorker(target.uid, target.name, target.path);
},
});
}
+2 -21
View File
@@ -18,8 +18,7 @@
*/
import UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js';
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import ensure_can_publish from './ensureCanPublish.js';
/**
* Opens the "Publish as website" flow for a directory, first walking the user
@@ -34,25 +33,7 @@ import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRe
* @returns {Promise<void>}
*/
const publish_as_website = async function ({ uid, name, path }) {
if ( window.require_email_verification_to_publish_website ) {
if ( window.user.is_temp &&
!await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) )
{
return;
}
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() )
{
return;
}
}
if ( ! await ensure_can_publish(window.require_email_verification_to_publish_website) ) return;
await UIWindowPublishWebsite(uid, name, path);
};
+9 -19
View File
@@ -140,6 +140,7 @@ const en = {
contact_us_verification_required: 'You must have a verified email address to use this.',
contain: 'Contain',
continue: 'Continue',
copied: 'Copied',
copy: 'Copy',
copy_link: 'Copy Link',
copying: 'Copying',
@@ -166,7 +167,6 @@ 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',
@@ -183,7 +183,6 @@ const en = {
directory: 'Directory',
disassociate: 'disassociate',
disk_storage: 'Disk Storage',
dismiss: 'Dismiss',
documents: 'Documents',
done: 'Done',
dont_allow: 'Don\'t Allow',
@@ -273,7 +272,6 @@ const en = {
modified: 'Modified',
monthly_credits: 'monthly credits',
move: 'Move',
moving: 'Moving',
moving_file: 'Moving %%',
my_websites: 'My Websites',
minimize: 'Minimize',
@@ -414,15 +412,15 @@ 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',
publish_as_website: 'Publish as website',
publish_as_serverless_worker: 'Publish as Worker',
publish_address: 'Address',
publish_name_required: 'Enter an address.',
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
puter_description: 'Puter is a privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time.',
ram: 'RAM',
reading: 'Reading %strong%',
@@ -678,13 +676,6 @@ 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',
@@ -736,11 +727,6 @@ 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}}',
@@ -770,6 +756,10 @@ const en = {
download_as_tar: 'Download as Tar',
sequencing: 'Sequencing %strong%',
worker: 'Worker',
worker_published: 'Your worker is live',
worker_sandboxed: 'Sandboxed',
worker_sandboxed_hint: 'Keeps the worker\'s data separate from the rest of your account.',
website_published: 'Your website is live',
zipping: 'Zipping %strong%',
tarring: 'Tarring %strong%',