mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9848cbdd54 | ||
|
|
5c36f1a57e | ||
|
|
a8ae457309 | ||
|
|
7f2b6dbe0a | ||
|
|
8b4b693b1d | ||
|
|
2e1e0fdc5b | ||
|
|
3bdfdc3903 | ||
|
|
acf566a88c | ||
|
|
472ddda32d | ||
|
|
cb77a50083 | ||
|
|
bb2cf5032b | ||
|
|
c558da83d8 | ||
|
|
a145509a0c | ||
|
|
0de0d592b1 | ||
|
|
e328ec4513 | ||
|
|
f2fb373fe6 | ||
|
|
3ecdb5f60a | ||
|
|
ad24ddf77e | ||
|
|
22697347ce | ||
|
|
a8f1352b45 | ||
|
|
ec0cb44f2c | ||
|
|
7b29038d20 | ||
|
|
b442fc54ea | ||
|
|
3a9d0b1b19 | ||
|
|
4db3893a02 | ||
|
|
f33240001f | ||
|
|
128f161480 | ||
|
|
38b7dc79f5 | ||
|
|
6613f6365e | ||
|
|
9a1082755e | ||
|
|
ddb86d31c6 | ||
|
|
211c1d9bb9 | ||
|
|
923034dcaf | ||
|
|
cefc7451e3 | ||
|
|
65efc7d81f | ||
|
|
cc9b08ac0e | ||
|
|
54f492bbb7 |
No files matched your search
@@ -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(
|
||||
|
||||
@@ -3672,18 +3672,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' },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4987,6 +4986,7 @@ export class FSService extends PuterService {
|
||||
if (collision && collision.uuid !== source.uuid) {
|
||||
name = await this.#resolveMoveCollision(
|
||||
userId,
|
||||
source,
|
||||
collision,
|
||||
destinationParent,
|
||||
name,
|
||||
@@ -5039,6 +5039,7 @@ export class FSService extends PuterService {
|
||||
if (raced && raced.uuid !== source.uuid) {
|
||||
name = await this.#resolveMoveCollision(
|
||||
userId,
|
||||
source,
|
||||
raced,
|
||||
destinationParent,
|
||||
name,
|
||||
@@ -5101,6 +5102,7 @@ export class FSService extends PuterService {
|
||||
*/
|
||||
async #resolveMoveCollision(
|
||||
userId: number,
|
||||
source: FSEntry,
|
||||
occupant: FSEntry,
|
||||
destinationParent: FSEntry,
|
||||
name: string,
|
||||
@@ -5112,6 +5114,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;
|
||||
}
|
||||
@@ -5127,6 +5130,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
|
||||
@@ -5196,6 +5213,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']);
|
||||
});
|
||||
});
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
+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.
Reference in new issue
Block a user