feat: 'Keep Both' option in name-conflict dialogs (#3496)

Pasting or moving onto an existing name only offered Replace or
Cancel. Add a macOS-style middle ground: Keep Both retries the
operation with dedupe_name, landing the item as "name (1).ext" and
leaving the existing item untouched.

- puter-js move.js now forwards dedupeName to the wire (copy already
  did; move dropped it).
- All four conflict dialogs offer the new button: copy_clipboard_items,
  copy_items and move_items in helpers.js, and the dashboard's
  moveClipboardItems. Multi-item selections show
  Replace / Replace all / Keep Both / Skip.
- New keep_both translation key (other locales fall back to English).
This commit is contained in:
Nariman Jelveh
2026-08-03 06:39:18 -07:00
committed by GitHub
parent 62be61642d
commit 33d7324a24
4 changed files with 35 additions and 4 deletions
+8
View File
@@ -3062,6 +3062,7 @@ const TabFiles = {
// Handle both object format { path, uid } and legacy string format
const source = item.uid || item.path || item;
let overwrite = overwrite_all;
let keep_both = false;
let retry;
do {
retry = false;
@@ -3070,6 +3071,9 @@ const TabFiles = {
source: source,
destination: destPath,
overwrite: overwrite,
// "Keep Both" conflict resolution: move under a
// deduped "name (1)" style name instead of overwriting
dedupeName: keep_both,
});
} catch ( err ) {
// Same conflict resolution as the desktop's move_items:
@@ -3080,6 +3084,7 @@ const TabFiles = {
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... multiple_items ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
{ label: i18n('keep_both'), value: 'keep_both' },
... multiple_items ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
@@ -3090,6 +3095,9 @@ const TabFiles = {
overwrite = true;
overwrite_all = true;
retry = true;
} else if ( alert_resp === 'keep_both' ) {
keep_both = true;
retry = true;
}
// skip/cancel: the item stays where it was cut from
} else {
+23 -4
View File
@@ -1277,6 +1277,7 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
let copy_path = window.clipboard[i].path;
let item_with_same_name_already_exists = true;
let overwrite = overwrite_all;
let keep_both = false;
latest_status = i18n('copying_file', copy_path);
progwin?.set_status(latest_status);
@@ -1298,8 +1299,10 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
source: copy_path,
destination: dest_path,
overwrite: overwrite || overwrite_all,
// if user is copying an item to where its source is, change the name so there is no conflict
dedupeName: dest_path === path.dirname(copy_path),
// dedupe when the user chose "Keep Both" on a conflict, or
// when copying an item to where its source is — either way
// the copy gets a "name (1)" style name instead of conflicting
dedupeName: keep_both || dest_path === path.dirname(copy_path),
});
// remove overwritten item from the DOM
@@ -1323,6 +1326,7 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (window.clipboard.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
{ label: i18n('keep_both'), value: 'keep_both' },
... (window.clipboard.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
@@ -1332,6 +1336,8 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
} else if ( alert_resp === 'replace_all' ) {
overwrite = true;
overwrite_all = true;
} else if ( alert_resp === 'keep_both' ) {
keep_both = true;
} else if ( alert_resp === 'skip' || alert_resp === 'cancel' ) {
item_with_same_name_already_exists = false;
}
@@ -1406,6 +1412,7 @@ window.copy_items = function (el_items, dest_path) {
let copy_path = $(el_items[i]).attr('data-path');
let item_with_same_name_already_exists = true;
let overwrite = overwrite_all;
let keep_both = false;
latest_status = i18n('copying_file', copy_path);
progwin?.set_status(latest_status);
@@ -1424,8 +1431,10 @@ window.copy_items = function (el_items, dest_path) {
source: copy_path,
destination: dest_path,
overwrite: overwrite || overwrite_all,
// if user is copying an item to where the source is, automatically change the name so there is no conflict
dedupeName: dest_path === path.dirname(copy_path),
// dedupe when the user chose "Keep Both" on a conflict, or
// when copying an item to where its source is — either way
// the copy gets a "name (1)" style name instead of conflicting
dedupeName: keep_both || dest_path === path.dirname(copy_path),
});
// remove overwritten item from the DOM
@@ -1449,6 +1458,7 @@ window.copy_items = function (el_items, dest_path) {
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (el_items.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
{ label: i18n('keep_both'), value: 'keep_both' },
... (el_items.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
@@ -1458,6 +1468,8 @@ window.copy_items = function (el_items, dest_path) {
} else if ( alert_resp === 'replace_all' ) {
overwrite = true;
overwrite_all = true;
} else if ( alert_resp === 'keep_both' ) {
keep_both = true;
} else if ( alert_resp === 'skip' || alert_resp === 'cancel' ) {
item_with_same_name_already_exists = false;
}
@@ -1765,6 +1777,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if an item with the same name already exists in the destination path
let item_with_same_name_already_exists = false;
let overwrite = overwrite_all;
let keep_both = false;
let untrashed_at_least_one_item = false;
// --------------------------------------------------------
@@ -1860,6 +1873,9 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
source: $(el_item).attr('data-uid'),
destination: dest_path,
overwrite: overwrite || overwrite_all,
// "Keep Both" conflict resolution: move under a deduped
// "name (1)" style name instead of overwriting
dedupeName: keep_both,
newName: new_name,
// recycling requires making all missing dirs
createMissingParents: recycling,
@@ -2020,6 +2036,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (el_items.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
{ label: i18n('keep_both'), value: 'keep_both' },
... (el_items.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
@@ -2029,6 +2046,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
} else if ( alert_resp === 'replace_all' ) {
overwrite = true;
overwrite_all = true;
} else if ( alert_resp === 'keep_both' ) {
keep_both = true;
} else if ( alert_resp === 'skip' || alert_resp === 'cancel' ) {
item_with_same_name_already_exists = false;
}
+1
View File
@@ -172,6 +172,7 @@ const en = {
item: 'item',
items_in_trash_cannot_be_renamed: 'This item can\'t be renamed because it\'s in the trash. To rename this item, first drag it out of the Trash.',
jpeg_image: 'JPEG image',
keep_both: 'Keep Both',
keep_in_taskbar: 'Keep in Taskbar',
language: 'Language',
license: 'License',
@@ -52,6 +52,9 @@ const move = defineOperation({
source,
destination,
overwrite: options.overwrite,
// give the moved item a deduped name ("x (1).txt") instead of
// conflicting — the "Keep Both" conflict resolution
dedupe_name: firstDefined(options, 'dedupeName', 'dedupe_name'),
new_name: newName,
create_missing_parents: firstDefined(options, 'createMissingParents', 'create_missing_parents'),
new_metadata: firstDefined(options, 'newMetadata', 'new_metadata'),