fix(puter-js): handle file picker cancel message

This commit is contained in:
Daniel Salazar committed 2026-10-16 20:31:19 -04:00
1 parent 471b900462
commit 0ec205eb2b
7 files changed
+150 -56

No files matched your search

+10 -1
View File
@@ -19,7 +19,16 @@ A set of key/value pairs that configure the directory picker dialog.
* `multiple` (Boolean): if set to `true`, user will be able to select multiple directories. Default is `false`.
## Return value
A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many directories were selected by the user.
A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many directories were selected by the user. If the user cancels, the promise stays pending.
The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels:
```js
const directory = await puter.ui.showDirectoryPicker().undefinedOnCancel;
if (directory === undefined) {
// the user closed the picker without choosing a directory
}
```
## Examples
+10 -1
View File
@@ -23,7 +23,16 @@ A set of key/value pairs that configure the file picker dialog.
* `path` (String): The initial directory to open the file picker in. Default is the user's Desktop. The special prefix `%appdata%` resolves to your app's private appdata directory (for example, `%appdata%/saves` opens that subdirectory inside your appdata).
## Return value
A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many files were selected by the user.
A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many files were selected by the user. If the user cancels, the promise stays pending.
The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels:
```js
const file = await puter.ui.showOpenFilePicker().undefinedOnCancel;
if (file === undefined) {
// the user closed the picker without choosing a file
}
```
## Examples
+9
View File
@@ -35,6 +35,15 @@ How `content` should be interpreted. One of `'url'`, `'move'`, or `'copy'`. If o
A `Promise` that resolves to an [`FSItem`](/Objects/fsitem) describing the saved file. If the user cancels, the promise stays pending.
The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels:
```js
const file = await puter.ui.showSaveFilePicker('Hello world!', 'hello.txt').undefinedOnCancel;
if (file === undefined) {
// the user closed the picker without saving
}
```
## Examples
```html
+6
View File
@@ -665,6 +665,12 @@ const ipc_listener = async (event, handled) => {
path: `/${ window.user.username }/Desktop`,
// this is the uuid of the window to which this dialog will return
parent_uuid: event.data.appInstanceID,
onDialogCancel: () => {
target_iframe.contentWindow.postMessage({
msg: 'directoryOpenCancelled',
original_msg_id: msg_id,
}, '*');
},
show_maximize_button: false,
show_minimize_button: false,
title: 'Open',
+15 -18
View File
@@ -535,6 +535,15 @@ const postAuthActions = async (action) => {
};
window.deliverPopupTokenToOpener = deliver_token_with_answer;
// The Cancel button closes the popup before on_close runs, so pickers
// report through onDialogCancel too. Escape only reaches on_close.
const tell_opener_cancelled = (msg) => {
window.opener?.postMessage({
msg,
original_msg_id: msg_id,
}, '*');
};
if ( action === 'show-open-file-picker' ) {
let options = window.url_query_params.get('options');
options = JSON.parse(options ?? '{}');
@@ -558,12 +567,8 @@ const postAuthActions = async (action) => {
iframe_msg_uid: msg_id,
center: true,
initiating_app_uuid: app_uid,
on_close: function () {
window.opener.postMessage({
msg: 'fileOpenCanceled',
original_msg_id: msg_id,
}, '*');
},
onDialogCancel: () => tell_opener_cancelled('fileOpenCancelled'),
on_close: () => tell_opener_cancelled('fileOpenCancelled'),
});
}
//--------------------------------------------------------------------------------------
@@ -588,12 +593,8 @@ const postAuthActions = async (action) => {
iframe_msg_uid: msg_id,
center: true,
initiating_app_uuid: app_uid,
on_close: function () {
window.opener.postMessage({
msg: 'directoryOpenCanceled',
original_msg_id: msg_id,
}, '*');
},
onDialogCancel: () => tell_opener_cancelled('directoryOpenCancelled'),
on_close: () => tell_opener_cancelled('directoryOpenCancelled'),
});
}
//--------------------------------------------------------------------------------------
@@ -632,12 +633,8 @@ const postAuthActions = async (action) => {
iframe_msg_uid: msg_id,
center: true,
initiating_app_uuid: app_uid,
on_close: function () {
window.opener.postMessage({
msg: 'fileSaveCanceled',
original_msg_id: msg_id,
}, '*');
},
onDialogCancel: () => tell_opener_cancelled('fileSaveCancelled'),
on_close: () => tell_opener_cancelled('fileSaveCancelled'),
onSaveFileDialogSave: async function (target_path, el_filedialog_window) {
$(el_filedialog_window).find('.window-disable-mask, .busy-indicator').show();
let busy_init_ts = Date.now();
+38 -36
View File
@@ -262,8 +262,16 @@ const createDeferred = () => {
return { promise, resolve, reject };
};
const FILE_SAVE_CANCELLED = Symbol('FILE_SAVE_CANCELLED');
const FILE_OPEN_CANCELLED = Symbol('FILE_OPEN_CANCELLED');
const PICKER_CANCELLED = Symbol('PICKER_CANCELLED');
// The popup pickers in older GUI builds spell these with one `l`.
const PICKER_CANCEL_MSGS = new Set([
'fileOpenCancelled',
'fileOpenCanceled',
'fileSaveCancelled',
'fileSaveCanceled',
'directoryOpenCancelled',
'directoryOpenCanceled',
]);
// A consent prompt covers a handful of scopes at most, and the popup carries
// them in its URL.
@@ -608,6 +616,19 @@ export class UIModule extends EventListener {
callback(value);
}
// A cancelled picker leaves its promise pending and settles only
// `undefinedOnCancel`, with `undefined`.
#registerPickerCallback (msgId, resolve, undefinedOnCancel) {
this.#callbackFunctions[msgId] = (result) => {
if ( result === PICKER_CANCELLED ) {
undefinedOnCancel.resolve(undefined);
return;
}
undefinedOnCancel.resolve(result);
resolve(result);
};
}
#postMessageAsync (name, args = {}) {
return new Promise(resolve => {
this.#postMessageWithCallback(name, resolve, args);
@@ -928,13 +949,8 @@ export class UIModule extends EventListener {
// execute callback
this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.saved_file));
}
else if ( e.data.msg === 'fileSaveCancelled' ) {
// execute callback
this.#callbackFunctions[e.data.original_msg_id](FILE_SAVE_CANCELLED);
}
else if ( e.data.msg === 'fileOpenCancelled' ) {
// execute callback
this.#callbackFunctions[e.data.original_msg_id](FILE_OPEN_CANCELLED);
else if ( PICKER_CANCEL_MSGS.has(e.data.msg) ) {
this.#callbackFunctions[e.data.original_msg_id](PICKER_CANCELLED);
}
else {
// execute callback
@@ -1310,14 +1326,17 @@ export class UIModule extends EventListener {
/**
* Shows a directory picker over the user's Puter storage. Resolves to one
* `FSItem`, or an array of them when multiple selection is allowed.
* `FSItem`, or an array of them when multiple selection is allowed. The
* returned promise also carries `undefinedOnCancel`, which resolves to
* `undefined` instead of staying pending when the user cancels.
*
* @param {{ multiple?: boolean }} [options]
* @param {(value: FSItem | FSItem[]) => void} [callback]
* @returns {Promise<FSItem | FSItem[]>}
* @returns {CancelAwarePromise<FSItem | FSItem[]>}
*/
showDirectoryPicker (options, callback) {
return new Promise((resolve, reject) => {
const undefinedOnCancel = createDeferred();
const resolveOnlyPromise = new Promise((resolve, reject) => {
if ( ! globalThis.open ) {
return reject('This API is not compatible in Web Workers.');
}
@@ -1348,9 +1367,10 @@ export class UIModule extends EventListener {
));
}
//register callback
this.#callbackFunctions[msg_id] = resolve;
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
});
resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise;
return resolveOnlyPromise;
};
/**
@@ -1361,7 +1381,7 @@ export class UIModule extends EventListener {
*
* @param {{ multiple?: boolean, accept?: string }} [options]
* @param {(value: FSItem | FSItem[]) => void} [callback]
* @returns {Promise<FSItem | FSItem[]>}
* @returns {CancelAwarePromise<FSItem | FSItem[]>}
*/
showOpenFilePicker (options, callback) {
const undefinedOnCancel = createDeferred();
@@ -1392,16 +1412,7 @@ export class UIModule extends EventListener {
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
));
}
//register callback
this.#callbackFunctions[msg_id] = (maybe_result) => {
// Only resolve cancel events if this was called with `.undefinedOnCancel`
if ( maybe_result === FILE_OPEN_CANCELLED ) {
undefinedOnCancel.resolve(undefined);
return;
}
undefinedOnCancel.resolve(maybe_result);
resolve(maybe_result);
};
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
});
resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise;
return resolveOnlyPromise;
@@ -1550,7 +1561,7 @@ export class UIModule extends EventListener {
* or the path of an existing file when 'move' or 'copy'
* @param {string} [suggestedName] name to prefill in the dialog
* @param {'url' | 'move' | 'copy'} [type] how `content` is read; inferred as 'url' for a URL
* @returns {Promise<FSItem>}
* @returns {CancelAwarePromise<FSItem>}
*/
showSaveFilePicker (content, suggestedName, type) {
const undefinedOnCancel = createDeferred();
@@ -1627,16 +1638,7 @@ export class UIModule extends EventListener {
// Same window, for the picker's own reply on the main listener.
this.#trackPickerPopup(popup);
}
//register callback
this.#callbackFunctions[msg_id] = (maybe_result) => {
// Only resolve cancel events if this was called with `.undefinedOnCancel`
if ( maybe_result === FILE_SAVE_CANCELLED ) {
undefinedOnCancel.resolve(undefined);
return;
}
undefinedOnCancel.resolve(maybe_result);
resolve(maybe_result);
};
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
});
resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise;
@@ -196,3 +196,65 @@ describe('host messages inside the desktop (env: app)', () => {
await expect(settled(promise)).resolves.toBe(PENDING);
});
});
// GUI popups and the desktop host have spelled the cancel replies both ways.
describe('picker cancel', () => {
const PICKERS = [
{
name: 'showOpenFilePicker',
open: (ui) => ui.showOpenFilePicker(),
popup: 'Puter: Open File',
msgs: ['fileOpenCancelled', 'fileOpenCanceled'],
},
{
name: 'showDirectoryPicker',
open: (ui) => ui.showDirectoryPicker(),
popup: 'Puter: Open Directory',
msgs: ['directoryOpenCancelled', 'directoryOpenCanceled'],
},
{
name: 'showSaveFilePicker',
open: (ui) => ui.showSaveFilePicker('hello', 'a.txt'),
popup: 'Puter: Save File',
msgs: ['fileSaveCancelled', 'fileSaveCanceled'],
},
];
const CASES = PICKERS.flatMap((picker) =>
picker.msgs.map((msg) => [picker.name, msg, picker]));
it.each(CASES)('%s on a third-party site handles %s', async (_name, msg, picker) => {
const ui = makeUI('web');
const promise = picker.open(ui);
const popup = openedPopups.get(picker.popup);
// The popup reads msg_id from its URL, so it echoes a string.
await deliver({ source: popup, origin: GUI_ORIGIN, data: { msg, original_msg_id: '1' } });
await expect(promise.undefinedOnCancel).resolves.toBeUndefined();
await expect(settled(promise)).resolves.toBe(PENDING);
});
it.each(PICKERS.map((picker) => [picker.name, picker]))('%s inside the desktop handles the host cancel', async (_name, picker) => {
const ui = makeUI('app');
const promise = picker.open(ui);
await deliver({
source: parentWindow,
origin: GUI_ORIGIN,
data: { msg: picker.msgs[0], original_msg_id: 1 },
});
await expect(promise.undefinedOnCancel).resolves.toBeUndefined();
await expect(settled(promise)).resolves.toBe(PENDING);
});
it('resolves undefinedOnCancel with the picked item', async () => {
const ui = makeUI('web');
const promise = ui.showOpenFilePicker();
await deliver({ source: null, origin: GUI_ORIGIN, data: filePicked(1) });
await expect(promise).resolves.toMatchObject({ name: 'a.txt' });
await expect(promise.undefinedOnCancel).resolves.toMatchObject({ name: 'a.txt' });
});
});