From 33d7324a24bea7c4ec4e72e76feba3d5fa6dfa84 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 3 Aug 2026 06:39:18 -0700 Subject: [PATCH] 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). --- src/gui/src/UI/Dashboard/TabFiles.js | 8 ++++++ src/gui/src/helpers.js | 27 ++++++++++++++++--- src/gui/src/i18n/translations/en.js | 1 + .../src/modules/FileSystem/operations/move.js | 3 +++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 9f81909ba..a1b706286 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -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 { diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index ca9cfa270..2c897fe12 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -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; } diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index cadb7c77b..ea2587ee7 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -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', diff --git a/src/puter-js/src/modules/FileSystem/operations/move.js b/src/puter-js/src/modules/FileSystem/operations/move.js index 3f5213145..98e635076 100644 --- a/src/puter-js/src/modules/FileSystem/operations/move.js +++ b/src/puter-js/src/modules/FileSystem/operations/move.js @@ -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'),