Compare commits

...
Author SHA1 Message Date
Nariman JelvehandClaude Opus 5.5 9848cbdd54 docs(fs): drop the note on overwriting a folder that holds the source
The refusal is an edge case, and its error message already explains it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:45:20 -07:00
Nariman Jelveh 5c36f1a57e fix(dashboard): don't let a Files folder wait for ever on a rename to open
Opening a row waits for a rename on its way, which settles in the last
step of the rename's handling. A failure after the rename itself (such as
refreshing the folder's websites) never got there, and the folder then
wouldn't open until it was listed again. Opening waits five seconds at
most.
2026-10-24 23:05:17 -07:00
Nariman Jelveh a8ae457309 fix(dashboard): keep the Files drop zone after files dragged in cross a folder row
Leaving a folder row while dragging files in from the computer also took
the highlight off the listing around it, as the row's notice bubbled up to
the listing's own. With the "can't drop here" pointer going by that
highlight, a drop on the listing after crossing a folder row was refused.
The row's notices stay with the row, and the listing goes by whether the
folder takes uploads.
2026-10-24 23:04:53 -07:00
Nariman Jelveh 7f2b6dbe0a docs(fs): note that move and copy never overwrite a folder holding the source 2026-10-24 22:56:58 -07:00
Nariman Jelveh 8b4b693b1d fix(dashboard): don't shrink Files → Shared's name column when it's dragged wider
In a narrow Shared listing, "Shared by" is laid out narrower beside a name
column with no set width. Dragging the name's divider gave it a width, at
which "Shared by" sprang back to its own and pushed the name narrower than
it started: dragging right made it 30px smaller, and there it stuck.
Sizing the name now keeps "Shared by" at the width it had on screen.
2026-10-24 22:55:11 -07:00
Nariman Jelveh 2e1e0fdc5b fix(dashboard): bring Files' Back/Forward along when a folder rename is undone
Renaming a folder points Back and Forward at its new name, but undoing the
rename left them there: Forward then led to "This folder couldn't be
opened" for a name the folder no longer had. Undo now takes them back to
the old name too.
2026-10-24 22:54:08 -07:00
Nariman Jelveh 3bdfdc3903 fix(dashboard): let Files' type-to-select find names with "i" in a Turkish browser
Typed letters were lower-cased for the browser's language and the names
they're matched against without one. In Turkish, "I" lower-cases to "ı",
so typing it never reached "Invoices". Both are now lower-cased alike.
2026-10-24 22:53:16 -07:00
Nariman Jelveh acf566a88c fix(dashboard): show "can't drop here" when dragging files from the computer where Files takes none
Files dragged in from the computer showed the copy pointer everywhere in
Files, then dropping on the Trash, in Shared, on a folder shared to view
only, or while a folder was loading did nothing. The pointer now shows the
drop isn't possible there.
2026-10-24 22:52:44 -07:00
Nariman Jelveh 472ddda32d fix(dashboard): move the keyboard focus off a Files arrow that disables itself
Pressing Enter on Back to reach the first folder disables Back while it has
the focus, and a disabled arrow is drawn at 10% opacity, focus ring
included, so the keyboard user lost track of where they were. The focus
now moves on to an arrow that still works, or to the listing.
2026-10-24 22:51:19 -07:00
Nariman Jelveh cb77a50083 perf(dashboard): refresh Files on return to the browser tab only when Files is showing
Coming back to the browser tab listed the Files folder again even with
another dashboard tab in front, and in Files → Shared that meant every
share all over again. The refresh now waits until Files is next shown.
2026-10-24 22:50:17 -07:00
Nariman Jelveh bb2cf5032b fix(dashboard): don't drop files added to a Files folder while a new folder is made
While New Folder (or New › file) waited for the server, every item added
to the folder on screen was dropped, not only the one being made, so a
file uploaded from another tab or device in that moment didn't show up
until the folder was listed again. Such items are now held until the
create is done and added then; the create's own comes through as an
update of the row already drawn for it.
2026-10-24 22:49:33 -07:00
Nariman Jelveh c558da83d8 fix(dashboard): keep the Files listing when a refresh can't reach the server
A refresh of the folder on screen that failed for any reason replaced the
listing with "This folder couldn't be opened." and took New Folder and
Upload away. Returning to the tab triggers one, so opening a laptop before
its Wi-Fi reconnected, or a moment's lost connection during a re-sort, left
a folder that was fine looking broken until it was opened again. A refresh
that doesn't reach the server now leaves the rows as they are; one the
server answers with an error (the folder is gone) still says so.
2026-10-24 22:48:09 -07:00
Nariman Jelveh a145509a0c fix(dashboard): give Files grid tiles both lines of their name
A tile has room for two lines of name, but names were cut to fit one:
"Quarterly financial report final version.pdf" showed as
"Quarterl…l version.pdf" over an empty second line. A name now shows whole
when it wraps into the two lines, and is cut in the middle, keeping its
extension, only as far as it has to be to fit them.

Where the browser wraps a name can't be told from text widths alone, so
the tiles are written and read back together, one layout a round rather
than one a tile.
2026-10-24 22:47:05 -07:00
Nariman Jelveh 0de0d592b1 fix(dashboard): keep every item, and empty folders' places, in a downloaded zip
Downloading several items as one zip keyed each by its name, so two items
with the same name (in Files → Shared, two people can each share a
"Notes.txt") came out as one file, the other silently gone. Such names
now get " (1)" and so on in the zip, as a folder does for a duplicate.

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

dedupedName moves to helpers/, now that the zip uses it as well.
2026-10-24 22:43:54 -07:00
Nariman Jelveh e328ec4513 fix(dashboard): keep a renamed item among its sharer's in Files → Shared
With Shared sorted by "Shared by", renaming an item shared with the user
to edit placed its row by name alone, so it could land among another
person's items until the listing was loaded again. A row put back in
place now goes by its sharer first there, as the listing does.
2026-10-24 22:41:57 -07:00
Nariman Jelveh f2fb373fe6 fix(dashboard): make double-click on a Files column divider fit the column
Double-clicking a column divider measured the text in system-ui, but the
listing renders in Inter, and took the cells' padding as 8px a side where
Size and Modified have 10. Right after "fit to content", the longest name
was still cut in the middle and every Modified cell ended in an ellipsis
("40 minutes a…"). Each column is now measured in its cells' own font and
padding.
2026-10-24 22:41:01 -07:00
Nariman Jelveh 3ecdb5f60a fix(dashboard): keep screen readers from reading Files' icon file names
The crumbs' separators, the folder sidebar's icons and every row's icon
were images with no alt text, so a screen reader announced each one, by
file name in some ("triangle-right.svg"), between every crumb and before
every name. They are decorative beside the text they sit next to and are
now marked so. The crumb of the folder on screen is marked as the current
page.
2026-10-24 22:39:39 -07:00
Nariman Jelveh ad24ddf77e fix(dashboard): keep Files' Back/Forward in step with folders moved or trashed
Back and Forward kept a folder's old path after it was moved or deleted,
here or from elsewhere: going into a folder, back out, and dragging it
somewhere else (or deleting it) left Forward leading to "This folder
couldn't be opened", with New Folder and Upload gone. History entries now
follow a moved folder and what is inside it, and a trashed or deleted
one's entries are dropped.
2026-10-24 22:38:28 -07:00
Nariman Jelveh 22697347ce fix(dashboard): open a Files folder under its new name when it's opened mid-rename
Double-clicking a folder right after typing its new name (the first click
sends the rename) opened it under the old path, which the rename then
took away: the view sat in a folder that no longer existed, with crumbs
and Back/Forward disagreeing about its name, and New Folder or Upload
there failed. Opening a row (double-click, tap, Enter, or Open from its
menu) now waits for a rename still on its way, and goes by one path.
2026-10-24 22:36:43 -07:00
Nariman Jelveh a8f1352b45 fix(dashboard): don't offer to publish a folder the user may not share from Files' folder menus
The background, crumb and sidebar menus offered Publish as Website for any
folder, so in a folder shared with the user to view or edit it opened the
publish dialog only for the server to refuse. Publishing takes the right
sharing does; the item now stays disabled without it, as on a folder row's
own menu.
2026-10-24 22:35:00 -07:00
Nariman Jelveh ec0cb44f2c fix(dashboard): stop Files' image preview from running out of read URLs
The preview fetched a read URL for every image it showed, and read URLs
share the access-token allowance of 20 an hour. Stepping through a folder
of images with the arrow keys spent it in seconds, after which the preview
(and any app asking for a read URL) failed for the rest of the hour. The
preview now reads the image directly and shows it from memory, freed when
it closes or moves on, and a held arrow key loads only the image it stops
on.
2026-10-24 22:33:51 -07:00
Nariman Jelveh 7b29038d20 fix(dashboard): update the Files footer when Enter ends a rename
Ending a rename with Enter selects the row, and then called the desktop
Explorer's footer update, which finds no footer in the dashboard. Renaming
a row from its menu while another was selected left the footer at
"1 item selected" with two rows selected.
2026-10-24 22:30:33 -07:00
Nariman Jelveh b442fc54ea fix(dashboard): count a new folder, not the old selection, in the Files footer
New Folder selects the new row, but the footer was updated before that and
kept describing what was selected before ("3 items selected"), as did the
phone's selection bar, whose buttons by then acted on the new folder. A
Shift+click also still extended from the old anchor. The footer now
follows the new selection, and the new row is the anchor.
2026-10-24 22:29:53 -07:00
Nariman Jelveh 3a9d0b1b19 fix(dashboard): don't offer Trash folders, or view-only shares' crumbs, as Files drop targets
Inside the Trash a folder row lit up as a drop target and then refused the
drop with an untranslated "Cannot move items into a deleted folder.", and a
crumb inside the Trash took the move, though only Restore may take an item
out of the Trash. A crumb of a folder shared with the user to view only
lit up too, and the drop ended in one refusal per item from the server.

Such folders no longer light up, and a drop on one goes back, as a drop on
a view-only folder row already did. A view-only crumb still opens on a
dwell.
2026-10-24 22:29:08 -07:00
Nariman Jelveh 4db3893a02 fix(dashboard): explain a Files action that fails on the network
A request that fails at the network level is rejected with no message.
New Folder then took its row back with no alert at all, and Restore and
the New menu's files showed "[object XMLHttpRequest]". A failed move from
the clipboard named the item and nothing else. Each now falls back to
"An unknown error occurred."
2026-10-24 22:26:40 -07:00
Nariman Jelveh f33240001f fix(dashboard): keep Files keys off a new folder until the server has made it
A new folder's row goes up with the name editor open before mkdir answers.
Clicks and menus already wait on it, but the keyboard didn't: pressing
Escape and then Delete on a slow connection sent a move for the row's
placeholder and showed "Moving New Folder: Subject does not exist", and
Enter opened the predicted path before it existed. Enter, Delete, Ctrl/Cmd+C
and Ctrl/Cmd+X now skip such a row, as do Cut and Delete in the
multi-select menu and the phone's selection bar.
2026-10-24 22:25:43 -07:00
Nariman Jelveh 128f161480 fix(dashboard): let Files' image preview be cancelled while it loads, and close it with the tab
The preview only counted as open once its image URL had come back (two
round-trips), so nothing could cancel it before then: Escape cleared the
selection and the image popped up anyway, a second Space opened it instead
of closing it, and opening a folder left the old image to appear over the
new one. It now counts as open from the start, so each of those cancels
it.

Browser Back/Forward to another dashboard tab left the preview floating
over that tab, where Escape and Space no longer reach it. A route change
away from Files now closes it, as it does Share and Properties.
2026-10-24 22:24:23 -07:00
Nariman Jelveh 38b7dc79f5 fix(dashboard): let Enter open the row the arrow keys moved to in Files
Clicking a sidebar folder, crumb or toolbar button leaves it focused, and
the arrow keys and type-to-select still move the row selection from there.
Enter, Space and the menu key then went to that focused control: after
clicking Documents and pressing Down twice, Enter opened Documents again
and dropped the selection instead of opening the highlighted row. The
listing now takes the focus when these keys move the selection, so the
keys after them act on the rows, and Shift+Tab goes back to the toolbar.
2026-10-24 22:21:04 -07:00
Nariman Jelveh 6613f6365e fix(dashboard): Ctrl/Cmd-drag a Files selection as a whole
Pressing Ctrl (Cmd on a Mac) on a row that was already selected took it out
of the selection at once, so a drag starting there collapsed the selection
to that one row: a Ctrl-drag of three files to copy them copied one, under
a ghost that still read "3". The row now leaves the selection when the
button is released without a drag, as in a desktop file manager, and a
drag carries the whole selection.
2026-10-24 22:17:03 -07:00
Nariman Jelveh 9a1082755e fix(dashboard): keep Files rows up while a refresh reads web link and app icons
A refresh of the folder on screen keeps its rows until the new listing is
in, so it doesn't flash blank. But it cleared them before reading the
icons, and a web link's or .app's icon is read from the file over the
network, so in such a folder every re-sort, upload or return to the tab
blanked the pane for those reads. The icons are now read before the rows
are swapped.
2026-10-24 22:15:16 -07:00
Nariman Jelveh ddb86d31c6 fix(dashboard): keep the Files selection when an upload lands in another folder
An upload into a folder other than the one on screen (a drop on a folder
row, a sidebar folder or a crumb, or Upload from a folder's menu) still
listed the folder on screen again when it finished. That cleared whatever
was selected there and left an open menu acting on rows no longer on the
page. Only an upload into the folder on screen refreshes it now.

The Upload picker ran its own copy of the upload flow; it now goes through
the same one as drops.
2026-10-24 22:14:15 -07:00
Nariman Jelveh 211c1d9bb9 fix(fs): refuse to replace a folder that contains the item being moved or copied
Moving or copying an item onto a name taken by one of its own parent
folders, with overwrite, removed that folder recursively first, and the
item went with it. Cutting the inner "photos" out of photos/photos, pasting
it next to the outer one and choosing Replace deleted both folders and
everything in them. Such an overwrite is now refused before anything is
removed.
2026-10-24 22:12:52 -07:00
Nariman Jelveh 923034dcaf fix(dashboard): give Files names a phone's full row, not a desktop column width
A narrow listing drops the columns for two-row rows, where each name has
the row's width. Names were still cut to the Name column's saved width
whenever the saved columns happened to fit, so a 120px column set on a
desktop cut names to about 100px on a phone with 280px to spare.
2026-10-24 22:10:17 -07:00
Nariman Jelveh cefc7451e3 fix(dashboard): don't let saved column widths squeeze out an auto-width name
Saved widths too wide for the listing fall back to the defaults, but only
when the Name column had a saved width too. Beside an auto-width name, a
Size or Modified column widened on a large screen kept its width on a
smaller one and squeezed the names to nothing, with the row scrolling
sideways. The check now counts an auto-width name at its 100px minimum.
2026-10-24 22:10:17 -07:00
Nariman Jelveh 65efc7d81f fix(dashboard): ask about "this item" when permanently deleting one Trash item
The Delete key and the phone selection bar's Delete Permanently asked
"Are you sure you want to permanently delete these items?" for a single
item too. One item now gets the single-item question, as from its own
menu. The three copies of the confirm-and-delete step (Delete key,
selection bar, multi-select menu) are now one.
2026-10-24 22:05:11 -07:00
Nariman Jelveh cc9b08ac0e fix(dashboard): sort Files names by number, and break size/date ties by name
Names were compared as plain text, so "file10" sorted before "file2" and
"dir10" before "dir2". They now compare as a file manager does, with runs
of digits by value.

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

The sort also parsed each trashed item's metadata on every comparison; it
now reads each entry's sort key once.
2026-10-24 22:03:48 -07:00
Nariman Jelveh 54f492bbb7 fix(dashboard): don't preview a file named after an image type, or a failed image
Space on a file named "png" (no extension) opened the image preview over a
broken image: the type was read from the last dot-separated part of the
name, which for an extensionless name is the whole name. It's now read from
the extension.

A preview whose image URL couldn't be fetched left the previous image up
under the newly selected row and raised an unhandled rejection. It now
closes instead.
2026-10-24 22:03:48 -07:00
12 changed files with 1201 additions and 373 deletions

No files matched your search

+38
View File
@@ -4749,6 +4749,25 @@ describe('FSService move', () => {
expect(await readBack(overwritten)).toBe('winner');
});
it('refuses to overwrite a directory the moved entry sits inside', async () => {
const outer = await fs.mkdir(user.userId, {
path: `${user.home}/Documents/nest/photos/photos`,
createMissingParents: true,
});
await writeFile(user, `${user.home}/Documents/nest/photos/photos/a.txt`, 'keep');
const nest = (await entryAt(user, '/Documents/nest'))!;
const refused = await caught(() =>
fs.move(user.userId, {
source: outer,
destinationParent: nest,
overwrite: true,
}),
);
expect(refused.statusCode).toBe(400);
expect(await entryAt(user, '/Documents/nest/photos/photos/a.txt')).toBeTruthy();
});
/**
* Reproduce an occupant arriving after the collision probe: the probe
* misses it (a concurrent move, or a replica that hasn't caught up) and the
@@ -5038,6 +5057,25 @@ describe('FSService copy', () => {
).toBe('cannot_copy_directory_into_itself');
});
it('refuses to overwrite a directory the copied entry sits inside', async () => {
const inner = await fs.mkdir(user.userId, {
path: `${user.home}/Documents/cp-nest/photos/photos`,
createMissingParents: true,
});
await writeFile(user, `${user.home}/Documents/cp-nest/photos/photos/a.txt`, 'keep');
const nest = (await entryAt(user, '/Documents/cp-nest'))!;
const refused = await caught(() =>
fs.copy(user.userId, {
source: inner,
destinationParent: nest,
overwrite: true,
}),
);
expect(refused.statusCode).toBe(400);
expect(await entryAt(user, '/Documents/cp-nest/photos/photos/a.txt')).toBeTruthy();
});
it('handles a destination collision by conflict, overwrite or dedupe', async () => {
const destination = (await entryAt(user, '/Desktop'))!;
const source = await writeFile(
+30 -12
View File
@@ -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
+526
View File
@@ -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']);
});
});
+11 -1
View File
@@ -398,6 +398,13 @@ async function UIDashboard (options) {
});
}
// Files' Back/Forward follows a folder moved elsewhere, and forgets
// one moved to the Trash.
if ( resp.is_dir && old_path && resp.path ) {
const trashed = resp.path.startsWith(`${window.trash_path}/`);
window.dashboard_object?.folderMoved?.(old_path, trashed ? null : resp.path);
}
// Create new item at destination if user is viewing that directory
if ( window.UIDashboardFileItem ) {
window.UIDashboardFileItem(resp);
@@ -407,6 +414,7 @@ async function UIDashboard (options) {
window.socket.on('item.removed', async (item) => {
if ( item.original_client_socket_id === window.socket.id ) return;
if ( item.descendants_only ) return;
if ( item.is_dir && item.path ) window.dashboard_object?.folderMoved?.(item.path, null);
// Match by uid when present (O(1) attribute selector; uids are UUIDs,
// safe to interpolate) and fall back to a path scan for minimal
@@ -533,10 +541,12 @@ async function UIDashboard (options) {
notifications.close({ restoreFocus: false });
// What was open over the tab being left goes with it, as on a
// sidebar click: the phone drawer, a menu, and Files' Share and
// Properties, which act on its rows (their changes are already saved).
// Properties, which act on its rows (their changes are already saved),
// and its image preview.
setMobileSidebar(false);
$('.context-menu').remove();
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
if ( tab !== 'files' && TabFiles.previewOpen ) TabFiles.closeImagePreview();
// Switch to correct tab
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
@@ -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';
+57
View File
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* @typedef {Object} SortKey
* @property {string} name - The name the row shows
* @property {number} [size]
* @property {number} [modified]
*/
// One collator for every comparison: localeCompare builds one per call.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
/**
* Orders names as a file manager does: case-insensitively, with runs of
* digits compared by value ("file2" before "file10"). Names equal but for
* case or accents still get a fixed order.
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
export function compareNames (a, b) {
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
}
/**
* Orders two listing entries by `column`, ascending. Equal sizes or dates
* fall back to the name, so their order is the same on every listing.
*
* @param {SortKey} a
* @param {SortKey} b
* @param {'name'|'size'|'modified'} column
* @returns {number}
*/
export function compareEntries (a, b, column) {
let comparison = 0;
if ( column === 'size' ) comparison = (a.size || 0) - (b.size || 0);
else if ( column === 'modified' ) comparison = (a.modified || 0) - (b.modified || 0);
return comparison || compareNames(a.name, b.name);
}
@@ -0,0 +1,62 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*/
import { describe, it, expect } from 'vitest';
import { compareEntries, compareNames } from './sortEntries.js';
const sortNames = names => [...names].sort(compareNames);
describe('compareNames', () => {
it('orders runs of digits by value', () => {
expect(sortNames(['file10.txt', 'file2.txt', 'file1.txt'])).toEqual(['file1.txt', 'file2.txt', 'file10.txt']);
expect(sortNames(['IMG_100', 'IMG_9', 'IMG_10'])).toEqual(['IMG_9', 'IMG_10', 'IMG_100']);
});
it('ignores case', () => {
expect(sortNames(['beta', 'Alpha', 'gamma'])).toEqual(['Alpha', 'beta', 'gamma']);
});
it('still orders names that differ only in case', () => {
expect(compareNames('a', 'A')).not.toBe(0);
expect(Math.sign(compareNames('a', 'A'))).toBe(-Math.sign(compareNames('A', 'a')));
});
});
describe('compareEntries', () => {
it('breaks a tie in size or date by name', () => {
const entries = [
{ name: 'zeta', size: 0, modified: 5 },
{ name: 'alpha', size: 0, modified: 5 },
{ name: 'dir10', size: 0, modified: 5 },
{ name: 'dir2', size: 0, modified: 5 },
];
const expected = ['alpha', 'dir2', 'dir10', 'zeta'];
expect([...entries].sort((a, b) => compareEntries(a, b, 'size')).map(e => e.name)).toEqual(expected);
expect([...entries].sort((a, b) => compareEntries(a, b, 'modified')).map(e => e.name)).toEqual(expected);
});
it('orders by the column before the name', () => {
const big = { name: 'a', size: 20, modified: 1 };
const small = { name: 'b', size: 10, modified: 2 };
expect(compareEntries(big, small, 'size')).toBeGreaterThan(0);
expect(compareEntries(big, small, 'modified')).toBeLessThan(0);
expect(compareEntries(big, small, 'name')).toBeLessThan(0);
});
it('reads a missing size or date as 0', () => {
expect(compareEntries({ name: 'a' }, { name: 'b', size: 1 }, 'size')).toBeLessThan(0);
});
});
+5
View File
@@ -4250,6 +4250,11 @@ body.myapps-reordering .myapps-tile {
outline-offset: -2px;
}
/* Holds the focus while the keys move the selection, which marks the place. */
.dashboard-section-files .files-tab .files:focus {
outline: none;
}
.dashboard-section-files .header .path-btn-disabled {
opacity: 0.1;
pointer-events: none;
+22 -17
View File
@@ -18,6 +18,7 @@
*/
import { createUploadThumbnailGenerator } from './services/pdfThumbnails/index.js';
import { dedupedName } from './helpers/dedupedName.js';
import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
@@ -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;