fix: progress window covering paste conflict dialog; ghost row after Replace

Pasting a copied file onto a name collision buried the Replace/Cancel
dialog under a stuck 'Preparing...' progress window, and answering
Replace left a stale duplicate row in every client.

- helpers.js: copy_clipboard_items armed its delayed progress window
  with a 0ms timer (its siblings use 2s), so the window opened
  instantly over the dialog. Use 2s, and in all three of
  copy_clipboard_items / copy_items / move_items pause the timer while
  a conflict dialog (or the own-location / trash-deny alerts) is
  waiting for input, re-arming it after. A window that opens
  mid-operation now shows the current file instead of a stuck
  'Preparing...', and the trash-deny bail no longer leaks a timer that
  opened an orphan window after the operation ended.
- LegacyFSController: /copy and /move dropped the legacy 'overwritten'
  response field and never emitted item.removed for the entry an
  overwrite deleted, so clients kept a ghost row until re-listing.
  Resolve the entry before the operation, return it, and emit
  item.removed on success.
- helpers.js: copy_items read resp[0].overwritten but removed
  resp.overwritten (always undefined), and the data-uid cleanup
  selectors were unquoted — invalid CSS when a UUID starts with a
  digit. Fixed all three sites.
This commit is contained in:
jelveh
2026-08-02 21:57:08 -07:00
parent 1a9f025db6
commit 1f591f1497
2 changed files with 145 additions and 29 deletions
@@ -166,8 +166,7 @@ export class LegacyFSController extends PuterController {
router.get('/get-launch-apps', apiOptions, async (req, res) => {
const recommendedSvc = this.services.recommendedApps as unknown as
| { getRecommendedApps?: () => Promise<unknown[]> }
| undefined;
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
const recommended = recommendedSvc?.getRecommendedApps
? await recommendedSvc.getRecommendedApps()
: [];
@@ -589,26 +588,61 @@ export class LegacyFSController extends PuterController {
'write',
);
// The v1 wire contract reports the entry an overwrite replaced
// (`overwritten`) so clients can drop its stale row/icon. The copy
// deletes that entry, so resolve it beforehand.
const overwriteRequested = getBoolean(body, 'overwrite') ?? false;
let overwrittenEntry = null;
if (overwriteRequested) {
const targetName = getString(body, 'new_name') ?? source.name;
const targetPath =
destinationParent.path === '/'
? `/${targetName}`
: `${destinationParent.path}/${targetName}`;
overwrittenEntry =
await this.stores.fsEntry.getEntryByPath(targetPath);
}
const copy = await this.services.fs.copy(userId, {
source,
destinationParent,
newName: getString(body, 'new_name'),
overwrite: getBoolean(body, 'overwrite') ?? false,
overwrite: overwriteRequested,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
});
await this.#emitGuiEvent('outer.gui.item.added', copy);
// Without this, every other client keeps a ghost row for the
// replaced entry until the directory is re-listed.
if (overwrittenEntry) {
await this.#emitGuiEvent(
'outer.gui.item.removed',
overwrittenEntry,
);
}
// Legacy response shape: `[{copied: fsentry, overwritten?}]`.
// Array is historical — originally supported bulk copy.
const copied = await toLegacyEntry(this.clients.event, copy, {
const legacyEntryOpts = {
fsEntryStore: this.stores.fsEntry,
userStore: this.stores.user as unknown as {
getById: (
id: number,
) => Promise<Record<string, unknown> | null>;
},
});
res.json([{ copied }]);
};
const copied = await toLegacyEntry(
this.clients.event,
copy,
legacyEntryOpts,
);
const overwritten = overwrittenEntry
? await toLegacyEntry(
this.clients.event,
overwrittenEntry,
legacyEntryOpts,
)
: undefined;
res.json([{ copied, ...(overwritten ? { overwritten } : {}) }]);
};
move = async (req: Request, res: Response): Promise<void> => {
@@ -640,36 +674,77 @@ export class LegacyFSController extends PuterController {
'write',
);
// The v1 wire contract reports the entry an overwrite replaced
// (`overwritten`) so clients can drop its stale row/icon. The move
// deletes that entry, so resolve it beforehand.
const overwriteRequested = getBoolean(body, 'overwrite') ?? false;
let overwrittenEntry = null;
if (overwriteRequested) {
const targetName = getString(body, 'new_name') ?? source.name;
const targetPath =
destinationParent.path === '/'
? `/${targetName}`
: `${destinationParent.path}/${targetName}`;
const existing =
await this.stores.fsEntry.getEntryByPath(targetPath);
// Moving an entry onto its own path is not an overwrite.
if (existing && existing.uuid !== source.uuid) {
overwrittenEntry = existing;
}
}
const moved = await this.services.fs.move(userId, {
source,
destinationParent,
newName: getString(body, 'new_name'),
overwrite: getBoolean(body, 'overwrite') ?? false,
overwrite: overwriteRequested,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
// Trash/restore rides on this: GUI sends
// `{ original_name, original_path, trashed_ts }` when moving into
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
old_path: oldPath,
});
// Without this, every other client keeps a ghost row for the
// replaced entry until the directory is re-listed.
if (overwrittenEntry) {
await this.#emitGuiEvent(
'outer.gui.item.removed',
overwrittenEntry,
);
}
// Legacy response shape: `{moved: fsentry, old_path}`.
const movedEntry = await toLegacyEntry(this.clients.event, moved, {
// Legacy response shape: `{moved: fsentry, old_path, overwritten?}`.
const legacyEntryOpts = {
fsEntryStore: this.stores.fsEntry,
userStore: this.stores.user as unknown as {
getById: (
id: number,
) => Promise<Record<string, unknown> | null>;
},
};
const movedEntry = await toLegacyEntry(
this.clients.event,
moved,
legacyEntryOpts,
);
const overwritten = overwrittenEntry
? await toLegacyEntry(
this.clients.event,
overwrittenEntry,
legacyEntryOpts,
)
: undefined;
res.json({
moved: movedEntry,
old_path: oldPath,
...(overwritten ? { overwritten } : {}),
});
res.json({ moved: movedEntry, old_path: oldPath });
};
delete = async (req: Request, res: Response): Promise<void> => {
@@ -1090,8 +1165,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1631,10 +1705,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
+55 -10
View File
@@ -1255,14 +1255,21 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
// only show progress window if it takes longer than 2s to copy
let progwin;
let progwin_timeout = setTimeout(async () => {
let latest_status;
const arm_progwin = () => setTimeout(async () => {
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
window.operation_cancelled[copy_op_id] = true;
},
});
}, 0);
// Opened mid-operation: show the file being copied rather than
// the default "Preparing..." status.
if ( latest_status ) {
progwin.set_status(latest_status);
}
}, 2000);
let progwin_timeout = arm_progwin();
const copied_item_paths = [];
@@ -1270,7 +1277,8 @@ 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;
progwin?.set_status(i18n('copying_file', copy_path));
latest_status = i18n('copying_file', copy_path);
progwin?.set_status(latest_status);
do {
if ( overwrite )
@@ -1296,7 +1304,7 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
// remove overwritten item from the DOM
if ( resp[0].overwritten?.id ) {
$(`.item[data-uid=${resp[0].overwritten.id}]`).removeItems();
$(`.item[data-uid='${resp[0].overwritten.id}']`).removeItems();
}
// copy new path for undo copy
@@ -1306,6 +1314,10 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
break;
} catch ( err ) {
if ( err.code === 'item_with_same_name_exists' ) {
// The operation is paused on user input, so pause the
// progress-window timer too — otherwise "Preparing..."
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
buttons: [
@@ -1314,6 +1326,7 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
... (window.clipboard.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
progwin_timeout = arm_progwin();
if ( alert_resp === 'replace' ) {
overwrite = true;
} else if ( alert_resp === 'replace_all' ) {
@@ -1371,14 +1384,21 @@ window.copy_items = function (el_items, dest_path) {
// only show progress window if it takes longer than 2s to copy
let progwin;
let progwin_timeout = setTimeout(async () => {
let latest_status;
const arm_progwin = () => setTimeout(async () => {
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
window.operation_cancelled[copy_op_id] = true;
},
});
// Opened mid-operation: show the file being copied rather than
// the default "Preparing..." status.
if ( latest_status ) {
progwin.set_status(latest_status);
}
}, 2000);
let progwin_timeout = arm_progwin();
const copied_item_paths = [];
@@ -1386,7 +1406,8 @@ 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;
progwin?.set_status(i18n('copying_file', copy_path));
latest_status = i18n('copying_file', copy_path);
progwin?.set_status(latest_status);
do {
if ( overwrite )
@@ -1409,7 +1430,7 @@ window.copy_items = function (el_items, dest_path) {
// remove overwritten item from the DOM
if ( resp[0].overwritten?.id ) {
$(`.item[data-uid=${resp.overwritten.id}]`).removeItems();
$(`.item[data-uid='${resp[0].overwritten.id}']`).removeItems();
}
// copy new path for undo copy
@@ -1419,6 +1440,10 @@ window.copy_items = function (el_items, dest_path) {
item_with_same_name_already_exists = false;
} catch ( err ) {
if ( err.code === 'item_with_same_name_exists' ) {
// The operation is paused on user input, so pause the
// progress-window timer too — otherwise "Preparing..."
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
buttons: [
@@ -1427,6 +1452,7 @@ window.copy_items = function (el_items, dest_path) {
... (el_items.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
progwin_timeout = arm_progwin();
if ( alert_resp === 'replace' ) {
overwrite = true;
} else if ( alert_resp === 'replace_all' ) {
@@ -1690,14 +1716,21 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// only show progress window if it takes longer than 2s to move
let progwin;
let progwin_timeout = setTimeout(async () => {
let latest_status;
const arm_progwin = () => setTimeout(async () => {
progwin = await UIWindowProgress({
operation_id: move_op_id,
on_cancel: () => {
window.operation_cancelled[move_op_id] = true;
},
});
// Opened mid-operation: show the file being moved rather than the
// default "Preparing..." status.
if ( latest_status ) {
progwin.set_status(latest_status);
}
}, 2000);
let progwin_timeout = arm_progwin();
// storing moved items for undo ability
const moved_items = [];
@@ -1721,7 +1754,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// cannot move item to its own path, skip it
if ( path.dirname($(el_item).attr('data-path')) === dest_path ) {
// pause the progress-window timer while waiting for the user
clearTimeout(progwin_timeout);
await UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>Cannot move item to its current location.`);
progwin_timeout = arm_progwin();
continue;
}
@@ -1794,6 +1830,9 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// moving an item into a trashed directory? deny.
else if ( dest_path.startsWith(window.trash_path) ) {
// the pending timer would otherwise open an orphan
// progress window after the operation already bailed
clearTimeout(progwin_timeout);
progwin?.close();
UIAlert('Cannot move items into a deleted folder.');
return;
@@ -1813,7 +1852,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// --------------------------------------------------------
// update progress window with current item being moved
// --------------------------------------------------------
progwin?.set_status(i18n(status_i18n_string, path_to_show_on_progwin));
latest_status = i18n(status_i18n_string, path_to_show_on_progwin);
progwin?.set_status(latest_status);
// execute move
let resp = await puter.fs.move({
@@ -1895,7 +1935,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if replacing an existing item, remove the old item that was just replaced
if ( resp.overwritten?.id ) {
$(`.item[data-uid=${resp.overwritten.id}]`).removeItems();
$(`.item[data-uid='${resp.overwritten.id}']`).removeItems();
}
// if this is trash, get original name from item metadata
@@ -1971,6 +2011,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
if ( err.code === 'item_with_same_name_exists' ) {
item_with_same_name_already_exists = true;
// The operation is paused on user input, so pause the
// progress-window timer too — otherwise "Preparing..."
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
buttons: [
@@ -1979,6 +2023,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
... (el_items.length > 1) ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
],
});
progwin_timeout = arm_progwin();
if ( alert_resp === 'replace' ) {
overwrite = true;
} else if ( alert_resp === 'replace_all' ) {