mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8462d30048 | ||
|
|
21846192d1 | ||
|
|
b9f0c8601d |
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -27,6 +27,10 @@ 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;
|
||||
@@ -38,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';
|
||||
|
||||
@@ -246,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', () => {
|
||||
@@ -294,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 () => {
|
||||
@@ -308,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();
|
||||
@@ -454,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', () => {
|
||||
@@ -502,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', () => {
|
||||
@@ -729,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']);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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 <1></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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -9279,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;
|
||||
@@ -9338,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;
|
||||
@@ -9425,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;
|
||||
@@ -9439,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;
|
||||
@@ -9471,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
-17
@@ -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';
|
||||
@@ -1961,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' ) {
|
||||
@@ -2631,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;
|
||||
@@ -2639,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' ) {
|
||||
@@ -2650,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;
|
||||
@@ -2665,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);
|
||||
}
|
||||
@@ -2688,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));
|
||||
}
|
||||
@@ -2762,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;
|
||||
File renamed without changes.
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -416,6 +417,10 @@ const en = {
|
||||
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%',
|
||||
@@ -751,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%',
|
||||
|
||||
|
||||
Reference in new issue
Block a user