mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-19 10:01:59 +00:00
fix(puter-js): handle file picker cancel message
This commit is contained in:
1 parent
471b900462
commit
0ec205eb2b
7 files changed
+150
-56
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user