mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
fix: show the new folder's row in the dashboard before mkdir answers
Both New Folder paths in the dashboard Files tab awaited mkdir before drawing anything, so on a slow connection the button appeared to do nothing for seconds. The toolbar path then did a full renderDirectory on top of that -- a second round-trip, and with the default eventual consistency the new folder could miss the listing entirely, leaving the rename editor unopened. Both now go through one createFolderInstant(), which renders the row first: a locally predicted name (mirroring the backend's " (N)" dedupe convention), a temporary uid, and the name editor already open. mkdir is still asked for the plain "New Folder" path with rename: true, so the server keeps owning deduplication; the prediction is only what we draw in the meantime, and the row corrects itself if the two disagree. On failure the row is withdrawn and the error is shown rather than swallowed. Renaming is the next thing the user does and it needs the real uid, so rename() awaits the create promise parked on the row. Everything else that acts on the item -- open, menus, drag -- sits out while the row is pending. mkdir's item.added comes back to the originating client (the event carries no original_client_socket_id) and cannot match a temporary uid, so _creatingItem still suppresses it, now as a counter: as a flag, one create finishing uncovered another still in flight. Two things found on the way: - directory_depth_limit_exceeded had no translation key, so that alert (including the pre-existing desktop create_folder path) rendered the raw slug. - The empty-directory notice is now restorable without refetching, since a withdrawn create has to put it back. It carries its own class because the loading overlay shares the same container. Verified end to end against a dev backend with mkdir slowed to 3s: toolbar and context-menu paths, rename and Escape before the response, two overlapping creates, and a rejected mkdir.
This commit is contained in:
@@ -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: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
|
||||
@@ -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,167 @@ 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(`<div class="files-empty-notice" style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
">
|
||||
No files in this directory.
|
||||
</div>`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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<void>}
|
||||
*/
|
||||
async createFolderInstant (targetPath) {
|
||||
const _this = this;
|
||||
if ( ! targetPath ) 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 +2321,7 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
if ( directoryContents.length === 0 ) {
|
||||
this.$el_window.find('.files-tab .files').append(`<div style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
">
|
||||
No files in this directory.
|
||||
`);
|
||||
this.renderEmptyPlaceholderIfNeeded();
|
||||
this.updateFooterStats();
|
||||
this.updateNavButtonStates();
|
||||
this.hideSpinner();
|
||||
@@ -2352,6 +2497,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 +2648,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 +2676,7 @@ const TabFiles = {
|
||||
_this.updateFooterStats();
|
||||
return;
|
||||
}
|
||||
if ( isPending() ) return;
|
||||
if ( isFolder === "1" ) {
|
||||
_this.pushNavHistory(file.path);
|
||||
_this.renderDirectory(file.path);
|
||||
@@ -2561,6 +2715,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 +2728,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 +2833,10 @@ const TabFiles = {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if ( isPending() ) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
el_item._contextMenuShownAt = Date.now();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -2746,7 +2914,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;
|
||||
}
|
||||
@@ -3794,44 +3962,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 +3972,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 +4020,7 @@ const TabFiles = {
|
||||
// File creation failed silently
|
||||
} finally {
|
||||
window.create_file = origCreateFile;
|
||||
_this._creatingItem = false;
|
||||
_this._creatingItem--;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string>} 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;
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user