diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 5aad4c400..a53cf4de4 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -32,6 +32,7 @@ import new_context_menu_item from '../../helpers/new_context_menu_item.js'; import publish_as_website from '../../helpers/publish_as_website.js'; import ContextMenuModal from './ContextMenu/ContextMenu.js'; import UIItemPropertiesModal from './UIItemPropertiesModal.js'; +import { dedupedName } from './dedupedName.js'; const icons = { document: ``, @@ -182,7 +183,15 @@ const TabFiles = { if ( displayName ) { $row.attr('data-name', displayName); $row.find('.item-name').text(displayName); - $row.find('.item-name-editor').val(displayName); + // Never write over an open editor — a just-created row has one + // focused already, and mkdir's own item.added lands here right + // after, which would wipe out whatever the user has typed. + // Visibility, not the -active class: the class outlives a + // cancelled edit, `.hide()` does not. + const $editor = $row.find('.item-name-editor'); + if ( ! $editor.is(':visible') ) { + $editor.val(displayName); + } } if ( file.path ) $row.attr('data-path', file.path); if ( typeof file.type !== 'undefined' ) $row.attr('data-type', file.type || ''); @@ -259,7 +268,10 @@ const TabFiles = { }; this.renderingDirectory = false; - this._creatingItem = false; + // Count, not flag: creates are started from a button that answers + // instantly now, so two can easily overlap and the first one finishing + // must not uncover the second. + this._creatingItem = 0; this.activeMenuFileUid = null; this.currentPath = null; this.currentPath = null; @@ -1241,24 +1253,8 @@ const TabFiles = { }); // New folder button - document.querySelector('.new-folder-btn').onclick = async () => { - if ( ! _this.currentPath ) return; - try { - const result = await puter.fs.mkdir({ - path: `${_this.currentPath}/New Folder`, - rename: true, - overwrite: false, - }); - await _this.renderDirectory(_this.currentPath); - // Find and select the new folder, then activate rename - const newFolderRow = this.$el_window.find(`.files-tab .row[data-name="${result.name}"]`); - if ( newFolderRow.length > 0 ) { - newFolderRow.addClass('selected'); - window.activate_item_name_editor(newFolderRow[0]); - } - } catch ( err ) { - // Folder creation failed silently - } + document.querySelector('.new-folder-btn').onclick = () => { + _this.createFolderInstant(_this.currentPath); }; // Upload input element @@ -1890,6 +1886,175 @@ const TabFiles = { // If not inserted, it belongs at the end (already there from append) }, + /** + * Puts the "No files in this directory." notice back when the listing has + * no rows left. The incremental add paths drop it before inserting their + * first row, so a create that is then undone (mkdir rejected) has to be + * able to restore it without refetching the directory. + * + * @returns {void} + */ + renderEmptyPlaceholderIfNeeded () { + const $files = this.$el_window.find('.files-tab .files'); + if ( $files.find('.item').length > 0 ) return; + // Match the notice by its own class: the loading overlay is a sibling + // in here too, and treating that as "already showing something" would + // leave the pane blank once the spinner is taken away. + if ( $files.find('.files-empty-notice').length > 0 ) return; + $files.append(`
+ No files in this directory. +
`); + }, + + /** + * Creates a folder in `targetPath`, putting its row on screen with the + * name editor open right away instead of after the mkdir round-trip — on a + * slow connection that wait is seconds of nothing happening. + * + * The row starts as a placeholder: a locally predicted name (see + * {@link dedupedName}) and a temporary uid, marked `data-pending`. When + * mkdir answers, the real fsentry is merged into the very object the row's + * listeners closed over, so rename/open/menus see the server identity from + * then on. The in-flight promise is parked on the row because renaming is + * the next thing the user does and it needs the real uid — `rename()` in + * createItemListeners() awaits it. If mkdir fails, the row is taken back. + * + * @param {string} targetPath - directory to create the folder in + * @returns {Promise} + */ + async createFolderInstant (targetPath) { + const _this = this; + if ( ! targetPath ) return; + + // A listing rebuild is already in flight. renderDirectory() clears + // `.files` before its readdir resolves, so a row drawn now survives the + // clear and ends up stranded in whatever directory the rebuild lands + // on — a row for `targetPath/New Folder` sitting in another folder's + // listing, with the rename editor open on it. Drop the click instead, + // exactly as renderDirectory drops navigation clicks while it renders. + if ( this.renderingDirectory ) return; + + // Invoked from a sidebar or breadcrumb menu for a folder that isn't + // the one on screen — go there first, so the row (and the rename that + // follows) happens where the folder lives rather than as a phantom row + // in the current listing. + if ( targetPath !== this.currentPath ) { + this.pushNavHistory(targetPath); + await this.renderDirectory(targetPath, { consistency: 'strong' }); + if ( this.currentPath !== targetPath ) return; + } + + const takenNames = this.$el_window.find('.files-tab .files .item') + .map((_i, row) => $(row).attr('data-name')).get(); + const placeholderName = dedupedName('New Folder', takenNames); + const placeholder = { + uid: `pending-${window.global_element_id++}`, + name: placeholderName, + path: `${targetPath}/${placeholderName}`, + is_dir: true, + immutable: false, + size: 0, + modified: Math.round(Date.now() / 1000), + }; + + // mkdir's item.added is delivered to this client too (the event carries + // no original_client_socket_id to filter on), and until mkdir answers + // the row's uid is a placeholder the dedupe check in UIDashboardFileItem + // can't match — so without this we'd end up with two rows. + this._creatingItem++; + + // Drop the "No files in this directory." placeholder before inserting + // the first row, otherwise it stays and overlaps the new item. + this.$el_window.find('.files-tab .files > div:not(.item)').remove(); + + await this.renderItem(placeholder); + const $row = this.$el_window.find(`.files-tab .files .item[data-uid='${placeholder.uid}']`); + if ( $row.length === 0 ) { + this._creatingItem--; + return; + } + + $row.attr('data-pending', '1'); + this.insertAtSortedPosition($row, placeholder); + this.applyColumnWidths(); + this.updateFooterStats(); + + this.$el_window.find('.files-tab .row.selected').removeClass('selected'); + $row.addClass('selected'); + window.activate_item_name_editor($row[0]); + + let detached = null; + const pending = (async () => { + try { + // Ask for the plain name and let the server dedupe, exactly as + // before — `placeholderName` is only what we drew in the + // meantime, never what we request. + const result = await puter.fs.mkdir({ + path: `${targetPath}/New Folder`, + rename: true, + overwrite: false, + }); + + // Re-rendered out from under us (navigation, refresh) while the + // request was in flight. The rebuilt listing may predate the + // folder, and the guard below suppressed its item.added, so + // hand it to the incremental adder once that guard lifts. + if ( ! document.body.contains($row[0]) ) { + detached = result; + return; + } + + // In place, because createItemListeners() closed over this + // object — that is what hands the row its real uid and path. + Object.assign(placeholder, result); + + $row.attr('data-uid', result.uid); + $row.attr('data-path', result.path); + $row.attr('data-modified', result.modified); + $row.removeAttr('data-pending'); + + // Our prediction lost a race with another client. Correct the + // row, but never over text the user has started typing. + if ( result.name !== placeholderName ) { + const $editor = $row.find('.item-name-editor'); + $row.attr('data-name', result.name); + $row.find('.item-name').text(result.name); + if ( $editor.val() === placeholderName ) { + $editor.val(result.name); + if ( $editor.is(':focus') ) $editor.select(); + } + } + } catch ( err ) { + // The row promised a folder that doesn't exist — take it back + // and say why, rather than letting it vanish unexplained. + $row.remove(); + _this.renderEmptyPlaceholderIfNeeded(); + _this.updateFooterStats(); + if ( err?.code === 'directory_depth_limit_exceeded' ) { + UIAlert({ message: i18n('directory_depth_limit_exceeded') }); + } else if ( err?.message ) { + UIAlert(err.message); + } + } finally { + _this._creatingItem--; + if ( detached ) window.UIDashboardFileItem(detached); + } + })(); + + $row[0]._pendingCreate = pending; + await pending; + }, + /** * Handles sort column selection or direction toggle. * @@ -2164,19 +2329,7 @@ const TabFiles = { } if ( directoryContents.length === 0 ) { - this.$el_window.find('.files-tab .files').append(`
- No files in this directory. - `); + this.renderEmptyPlaceholderIfNeeded(); this.updateFooterStats(); this.updateNavButtonStates(); this.hideSpinner(); @@ -2352,6 +2505,13 @@ const TabFiles = { let itemWasSelectedOnMousedown = false; let lastPointerType = null; + // A row rendered ahead of its mkdir (see createFolderInstant) carries a + // placeholder uid and path, so anything that acts on the item itself — + // opening it, its menus, dragging it — has to sit out until the real + // fsentry arrives. Renaming is the exception: it is what the open name + // editor is for, and it waits on the promise instead. + const isPending = () => el_item.getAttribute('data-pending') === '1'; + el_item.onpointerdown = (e) => { if ( e.target.classList.contains('item-more') ) return; if ( el_item.classList.contains('header') ) return; @@ -2496,6 +2656,7 @@ const TabFiles = { el_item.onclick = (e) => { if ( e.target.classList.contains('item-more') ) { + if ( isPending() ) return; this.handleMoreClick(el_item, file, e.target); return; } @@ -2523,6 +2684,7 @@ const TabFiles = { _this.updateFooterStats(); return; } + if ( isPending() ) return; if ( isFolder === "1" ) { _this.pushNavHistory(file.path); _this.renderDirectory(file.path); @@ -2561,6 +2723,7 @@ const TabFiles = { if ( e.target.classList.contains('item-name-editor') ) { return; } + if ( isPending() ) return; if ( isFolder === "1" ) { _this.pushNavHistory(file.path); _this.renderDirectory(file.path); @@ -2573,12 +2736,21 @@ const TabFiles = { // -------------------------------------------------------- // Rename // -------------------------------------------------------- - function rename () { + async function rename () { if ( rename_cancelled ) { rename_cancelled = false; return; } + // The row may have been drawn before its mkdir answered; renaming + // needs the real uid, and the server's name is what we diff + // against, so read neither until the create has settled. + if ( el_item._pendingCreate ) { + await el_item._pendingCreate; + // mkdir failed and took the row with it. + if ( ! document.body.contains(el_item) ) return; + } + const old_name = $(el_item).attr('data-name'); const old_path = $(el_item).attr('data-path'); const new_name = $(el_item_name_editor).val(); @@ -2669,6 +2841,10 @@ const TabFiles = { e.preventDefault(); return; } + if ( isPending() ) { + e.preventDefault(); + return; + } el_item._contextMenuShownAt = Date.now(); e.preventDefault(); e.stopPropagation(); @@ -2746,7 +2922,7 @@ const TabFiles = { return false; } - if ( $(el_item).attr('data-immutable') !== '0' ) { + if ( $(el_item).attr('data-immutable') !== '0' || isPending() ) { $('body').css('cursor', ''); return false; } @@ -2870,6 +3046,12 @@ const TabFiles = { _this.folderDwellTimer = null; _this.folderDwellTarget = null; + // A row drawn ahead of its mkdir has a predicted path, not + // a real one. Moving into it would either fail or — because + // move treats a non-existent destination as a rename target + // — quietly rename the dragged file to "New Folder". + if ( isPending() ) return; + const draggedPath = $(ui.draggable).attr('data-path'); if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { return; @@ -2910,6 +3092,11 @@ const TabFiles = { over: function (_event, ui) { if ( $(ui.draggable).hasClass('row') ) { + // Still waiting on mkdir: don't offer it as a drop + // target, and don't spring-load into a path that may + // not exist yet (see the drop handler above). + if ( isPending() ) return; + $(el_item).addClass('selected'); const _this = TabFiles; @@ -2973,6 +3160,7 @@ const TabFiles = { if ( ! e.dataTransfer?.types?.includes('Files') ) { return; } + if ( isPending() ) return; const targetPath = $(el_item).attr('data-path'); @@ -2996,6 +3184,7 @@ const TabFiles = { if ( ! e.dataTransfer?.types?.includes('Files') ) { return; } + if ( isPending() ) return; const targetPath = $(el_item).attr('data-path'); @@ -3794,44 +3983,9 @@ const TabFiles = { // Override the "New Folder" onClick to refresh and activate rename if ( newMenuItems.items && newMenuItems.items.length > 0 ) { const folderItem = newMenuItems.items[0]; // First item is "New Folder" - folderItem.onClick = async () => { + folderItem.onClick = () => { $('.context-menu').remove(); - _this._creatingItem = true; - try { - const result = await puter.fs.mkdir({ - path: `${targetPath}/New Folder`, - rename: true, - overwrite: false, - }); - if ( targetPath === _this.currentPath ) { - // Remove empty-directory placeholder if present - _this.$el_window.find('.files-tab .files > div:not(.item)').remove(); - // Add the new folder incrementally - await _this.renderItem(result); - const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); - if ( $newRow.length > 0 ) { - _this.insertAtSortedPosition($newRow, result); - _this.applyColumnWidths(); - _this.updateFooterStats(); - } - } else { - // Created via a sidebar/breadcrumb right-click on a - // folder that isn't the one on screen — navigate to - // it so the rename happens where the folder lives, - // not as a phantom row in the current listing. - _this.pushNavHistory(targetPath); - await _this.renderDirectory(targetPath, { consistency: 'strong' }); - } - const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); - if ( $newRow.length > 0 ) { - $newRow.addClass('selected'); - window.activate_item_name_editor($newRow[0]); - } - } catch ( err ) { - // Folder creation failed silently - } finally { - _this._creatingItem = false; - } + _this.createFolderInstant(targetPath); }; // Override other file creation items to intercept create_file, @@ -3839,7 +3993,7 @@ const TabFiles = { const wrapWithDashboardRename = (originalOnClick) => { return async () => { $('.context-menu').remove(); - _this._creatingItem = true; + _this._creatingItem++; // Temporarily intercept create_file to capture the upload promise let uploadPromise = null; @@ -3887,7 +4041,7 @@ const TabFiles = { // File creation failed silently } finally { window.create_file = origCreateFile; - _this._creatingItem = false; + _this._creatingItem--; } }; }; diff --git a/src/gui/src/UI/Dashboard/dedupedName.js b/src/gui/src/UI/Dashboard/dedupedName.js new file mode 100644 index 000000000..cffb962a8 --- /dev/null +++ b/src/gui/src/UI/Dashboard/dedupedName.js @@ -0,0 +1,54 @@ +/* + * 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 . + */ + +import path from '../../lib/path.js'; + +/** Matches the backend's cap in FSService#findDedupedName. */ +const MAX_SUFFIX = 100_000; + +/** + * Predict the name the backend will settle on when it dedupes `name` against + * the entries already in a directory: the name itself if free, otherwise + * `base (1)`, `base (2)`, ... with any extension kept at the end. Mirrors the + * ` (N)` convention of FSService#findDedupedName. + * + * This is only a prediction from what the client can see — the server remains + * the authority, and a caller that renders the predicted name optimistically + * has to correct itself if the two disagree. + * + * @param {string} name - the desired name, e.g. 'New Folder' + * @param {Iterable} takenNames - names already in the directory + * @returns {string} + */ +export function dedupedName (name, takenNames) { + const taken = new Set(); + for ( const taken_name of takenNames ) { + if ( typeof taken_name === 'string' ) taken.add(taken_name.toLowerCase()); + } + + if ( ! taken.has(name.toLowerCase()) ) return name; + + const extension = path.extname(name); + const base = path.basename(name, extension); + for ( let suffix = 1; suffix < MAX_SUFFIX; suffix++ ) { + const candidate = `${base} (${suffix})${extension}`; + if ( ! taken.has(candidate.toLowerCase()) ) return candidate; + } + return name; +} diff --git a/src/gui/src/UI/Dashboard/dedupedName.test.js b/src/gui/src/UI/Dashboard/dedupedName.test.js new file mode 100644 index 000000000..3a09b213d --- /dev/null +++ b/src/gui/src/UI/Dashboard/dedupedName.test.js @@ -0,0 +1,55 @@ +/* + * 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 { dedupedName } from './dedupedName.js'; + +describe('dedupedName', () => { + it('keeps the name when nothing collides', () => { + expect(dedupedName('New Folder', [])).toBe('New Folder'); + expect(dedupedName('New Folder', ['Documents', 'Photos'])).toBe('New Folder'); + }); + + it('starts the suffix at (1), matching the backend', () => { + expect(dedupedName('New Folder', ['New Folder'])).toBe('New Folder (1)'); + }); + + it('skips suffixes that are already taken', () => { + const taken = ['New Folder', 'New Folder (1)', 'New Folder (2)']; + expect(dedupedName('New Folder', taken)).toBe('New Folder (3)'); + }); + + it('fills the first gap in the suffix sequence', () => { + const taken = ['New Folder', 'New Folder (2)']; + expect(dedupedName('New Folder', taken)).toBe('New Folder (1)'); + }); + + it('compares case-insensitively', () => { + expect(dedupedName('New Folder', ['new folder'])).toBe('New Folder (1)'); + }); + + it('keeps the extension after the suffix', () => { + expect(dedupedName('notes.txt', ['notes.txt'])).toBe('notes (1).txt'); + }); + + it('treats a dotfile as having no extension', () => { + expect(dedupedName('.env', ['.env'])).toBe('.env (1)'); + }); + + it('ignores non-string entries', () => { + expect(dedupedName('New Folder', [null, undefined, 'New Folder'])).toBe('New Folder (1)'); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 7591a1b51..bef9548a6 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -148,6 +148,7 @@ const en = { desktop_background_fit: 'Fit', developers: 'Developers', dir_published_as_website: '%strong% has been published to:', + directory_depth_limit_exceeded: 'This folder is nested too deeply. Move it somewhere closer to the top before adding more folders inside it.', disable_2fa: 'Disable 2FA', disable_2fa_confirm: 'Are you sure you want to disable 2FA?', disable_2fa_instructions: 'Enter your password to disable 2FA.',