fix: surface name collisions when pasting instead of failing silently (#3492)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

Cut+paste onto a folder containing an item with the same name failed
with no feedback: the dashboard's moveClipboardItems swallowed the
error into console.error and cleared the clipboard, and the rewritten
backend broke the v1 wire contract the GUI's conflict prompts key on.

- FSService: move() and copy() collisions again return
  item_with_same_name_exists + entry_name (the contract the write path
  already preserves) instead of a generic 'conflict' code. This also
  restores the desktop's existing Replace/Skip dialogs, which were
  silently broken against the new backend.
- Dashboard: moveClipboardItems now mirrors the desktop's move_items
  conflict flow (Replace / Replace all / Skip / Cancel, retry with
  overwrite) and surfaces other move errors in an alert.
- keyboard.js: the desktop's global Ctrl+V handler threw an uncaught
  TypeError on every paste in dashboard mode (its file container has
  no data-path); guard it — which also prevents a double-paste if
  dashboard containers ever gain one.
This commit is contained in:
Nariman Jelveh
2026-08-02 21:33:01 -07:00
committed by GitHub
parent a4667073f5
commit 1a9f025db6
3 changed files with 65 additions and 10 deletions
+12 -2
View File
@@ -3496,10 +3496,16 @@ export class FSService extends PuterService {
} else if (input.dedupeName) {
name = await this.#findDedupedName(destinationParent, name);
} else {
// v1 wire contract: clients (the GUI's move/paste flows among
// them) key on `item_with_same_name_exists` + `entry_name` to
// offer a replace/skip prompt.
throw new HttpError(
409,
`An entry already exists at ${targetPath}`,
{ legacyCode: 'conflict' },
{
legacyCode: 'item_with_same_name_exists',
fields: { entry_name: name },
},
);
}
}
@@ -3599,10 +3605,14 @@ export class FSService extends PuterService {
} else if (input.dedupeName) {
name = await this.#findDedupedName(destinationParent, name);
} else {
// v1 wire contract, as in move() above.
throw new HttpError(
409,
`An entry already exists at ${targetPath}`,
{ legacyCode: 'conflict' },
{
legacyCode: 'item_with_same_name_exists',
fields: { entry_name: name },
},
);
}
}
+44 -8
View File
@@ -3052,17 +3052,53 @@ const TabFiles = {
return;
}
const { html_encode } = window;
const multiple_items = window.clipboard.length > 1;
// Set once the user picks "Replace all" on a conflict; later items
// then overwrite without asking again.
let overwrite_all = false;
for ( const item of window.clipboard ) {
// Handle both object format { path, uid } and legacy string format
const source = item.uid || item.path || item;
try {
await puter.fs.move({
source: source,
destination: destPath,
});
} catch ( err ) {
console.error('Failed to move item:', err);
}
let overwrite = overwrite_all;
let retry;
do {
retry = false;
try {
await puter.fs.move({
source: source,
destination: destPath,
overwrite: overwrite,
});
} catch ( err ) {
// Same conflict resolution as the desktop's move_items:
// ask, then retry with overwrite or leave the item be.
if ( err.code === 'item_with_same_name_exists' ) {
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... multiple_items ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
... multiple_items ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
if ( alert_resp === 'replace' ) {
overwrite = true;
retry = true;
} else if ( alert_resp === 'replace_all' ) {
overwrite = true;
overwrite_all = true;
retry = true;
}
// skip/cancel: the item stays where it was cut from
} else {
console.error('Failed to move item:', err);
const item_name = String(item.path || source).split('/').pop();
UIAlert(`<p>Moving <strong>${html_encode(item_name)}</strong></p>${html_encode(err.message ?? '')}`);
}
}
} while ( retry );
}
window.clipboard = [];
+9
View File
@@ -870,6 +870,15 @@ $(document).bind('keyup keydown', async function (e) {
if ( parent_container ) {
target_el = parent_container;
target_path = $(parent_container).attr('data-path');
// No path means this container isn't a filesystem view this
// handler can paste into — the dashboard's Files tab is one such
// (it has its own paste handler); pasting here anyway would
// double-move the clipboard, and reading .startsWith off the
// undefined path used to throw on every paste in dashboard mode.
if ( ! target_path )
{
return;
}
// don't allow pasting in Trash
if ( (target_path === window.trash_path || target_path.startsWith(`${window.trash_path }/`)) && window.clipboard_op !== 'move' )
{