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.',