| 1 |
|
| 2 |
* The host half of `Action::by_host`. |
| 3 |
* |
| 4 |
* quasicoherent `a81384d4` ruled that some calls are the host's rather than the |
| 5 |
* renderer's, and quasi-webview's answer is to emit `data-sends="<url>"` with no |
| 6 |
* transport at all: no verb, no trigger, no href. Something has to act on that, |
| 7 |
* and on this app that something is this file. |
| 8 |
* |
| 9 |
* WHY THIS FILE EXISTS AT ALL, since the 2026-08-22 swap deleted 84 scripts and |
| 10 |
* the whole point was that screens are described. Because picking a file is not |
| 11 |
* describable and was never going to be. A described `FieldKind::File` renders |
| 12 |
* `<input type="file">`, htmx submits it urlencoded, and a browser reports a |
| 13 |
* masked filename rather than a path; multipart is refused outright by |
| 14 |
* `quasi_http::is_form`, on the grounds that a description has no word for a |
| 15 |
* byte stream. So the field could deliver neither bytes nor a path, and the |
| 16 |
* three imports and the project attach were quietly broken from the moment the |
| 17 |
* described screens started serving. |
| 18 |
* |
| 19 |
* What used to work was the JavaScript calling `window.__TAURI__.dialog.open()` |
| 20 |
* and handing the command an absolute path. That is exactly the "host that |
| 21 |
* already knows the chain" the ruling describes, and this is it, kept to the one |
| 22 |
* job. |
| 23 |
* |
| 24 |
* WHAT IT IS NOT. Not a place for behaviour that belongs in a description. If |
| 25 |
* something here grows past picking a file and handing back what the host got, |
| 26 |
* that is a described screen wearing a script, and the thing to do is describe |
| 27 |
* it. |
| 28 |
* |
| 29 |
* THE SECOND JOB, added 2026-08-22, is `data-mount` and it is the same job in a |
| 30 |
* different coat: a webview cannot put up a window any more than it can open a |
| 31 |
* file dialog, so the description says where and this does it. What makes it |
| 32 |
* not the thing the paragraph above forbids is that it answers a word -- |
| 33 |
* `Action::elsewhere`, quasi 0.55.0 -- rather than inventing a behaviour. Max |
| 34 |
* ruled that (goingson `3fb2526a`), against two cheaper options that would each |
| 35 |
* have put the second window outside the description: a bespoke branch here, or |
| 36 |
* a menu item no screen can see. |
| 37 |
* |
| 38 |
* THE THIRD JOB, added 2026-08-30, is the theme picker, and it passes the same |
| 39 |
* test. The described word is `FieldKind::Theme`, and reaching the root of a |
| 40 |
* document that is already open is genuinely impossible for the renderer: the |
| 41 |
* answer to the write is swapped in by htmx, which parses the response's |
| 42 |
* `<html>` away and never touches `document.documentElement`. So the sheet |
| 43 |
* carries every theme keyed by `data-theme` (see `src/quasi/theming.rs`) and |
| 44 |
* this sets the attribute. |
| 45 |
* |
| 46 |
* The test for a fourth job is the same one: is there a described word it is |
| 47 |
* answering, and is the thing it does genuinely impossible for a renderer? |
| 48 |
|
| 49 |
(() => { |
| 50 |
|
| 51 |
* |
| 52 |
* `data-vals` holds the action's own params, which is where the accept list |
| 53 |
* travels: the description says what it will take, and turning that into a |
| 54 |
* platform file dialog's filter is the host's business. |
| 55 |
function filters(vals) { |
| 56 |
if (!vals.accept) return undefined; |
| 57 |
const extensions = vals.accept |
| 58 |
.split(',') |
| 59 |
.map((one) => one.trim().replace(/^\./, '')) |
| 60 |
.filter(Boolean); |
| 61 |
if (extensions.length === 0) return undefined; |
| 62 |
return [{ name: vals.name || 'Files', extensions }]; |
| 63 |
} |
| 64 |
|
| 65 |
|
| 66 |
* description said. |
| 67 |
* |
| 68 |
* The send goes through `htmx.ajax` rather than `fetch`, because the answer |
| 69 |
* is an ordinary fragment with the ordinary swap headers on it. Doing the |
| 70 |
* request by hand would mean reimplementing retarget, reswap and |
| 71 |
* out-of-band swaps, and getting one of them subtly wrong. |
| 72 |
async function perform(element) { |
| 73 |
const url = element.getAttribute('data-sends'); |
| 74 |
if (!url) return; |
| 75 |
|
| 76 |
let vals = {}; |
| 77 |
try { |
| 78 |
vals = JSON.parse(element.getAttribute('data-vals') || '{}'); |
| 79 |
} catch (_) { |
| 80 |
|
| 81 |
* what we can rather than dropping the whole interaction. |
| 82 |
} |
| 83 |
|
| 84 |
const dialog = window.__TAURI__ && window.__TAURI__.dialog; |
| 85 |
if (!dialog) { |
| 86 |
|
| 87 |
* because a control that looks pressable and is not is the failure |
| 88 |
* this whole file is fixing. |
| 89 |
console.error('[host] no Tauri dialog available; cannot pick a file'); |
| 90 |
return; |
| 91 |
} |
| 92 |
|
| 93 |
const picked = await dialog.open({ multiple: false, filters: filters(vals) }); |
| 94 |
if (!picked) return; |
| 95 |
|
| 96 |
window.htmx.ajax('POST', url, { |
| 97 |
source: element, |
| 98 |
values: Object.assign({}, vals, { file: picked }), |
| 99 |
}); |
| 100 |
} |
| 101 |
|
| 102 |
|
| 103 |
* |
| 104 |
* `quasi-webview` emits `data-mount` with no transport at all -- no verb, |
| 105 |
* no trigger, no href -- for the same reason it does that for `data-sends`: |
| 106 |
* every one of them would put the answer in the mount the control is |
| 107 |
* already in, which is what the mark says not to do. |
| 108 |
* |
| 109 |
* The command takes the path and checks it before opening anything. This |
| 110 |
* file deliberately does no checking of its own: two answers to which |
| 111 |
* addresses may become a window is one answer too many, and the one that |
| 112 |
* matters is the one in Rust. |
| 113 |
async function mount(element) { |
| 114 |
const path = element.getAttribute('data-mount'); |
| 115 |
if (!path) return; |
| 116 |
|
| 117 |
const core = window.__TAURI__ && window.__TAURI__.core; |
| 118 |
if (!core) { |
| 119 |
|
| 120 |
* the missing dialog is: a control that looks pressable and is not |
| 121 |
* is the failure this whole file is fixing. |
| 122 |
console.error('[host] no Tauri core available; cannot open a window'); |
| 123 |
return; |
| 124 |
} |
| 125 |
|
| 126 |
try { |
| 127 |
await core.invoke('open_compose_window', { path }); |
| 128 |
} catch (error) { |
| 129 |
console.error('[host] could not open a window on', path, error); |
| 130 |
} |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
* `makeover::THEME_ATTRIBUTE`, which the sheet is emitted from, so a change |
| 135 |
* there is a change here. |
| 136 |
* |
| 137 |
* The value is whatever the picker carries and is passed through unread, |
| 138 |
* Follow System included -- that choice has a keyed block of its own, so |
| 139 |
* there is never a reason to remove the attribute. Removing it would fall |
| 140 |
* back to the unkeyed blocks, and those are whatever was pinned when the |
| 141 |
* app started. |
| 142 |
const THEME_ATTRIBUTE = 'data-theme'; |
| 143 |
|
| 144 |
|
| 145 |
* for the `user_config` row behind it. See `quasi::settings`. |
| 146 |
const THEME_KEY = 'theme'; |
| 147 |
|
| 148 |
|
| 149 |
* |
| 150 |
* Nothing is validated. A value naming no theme matches no keyed block and |
| 151 |
* the unkeyed ones stay in force, which is the same fallback the sheet |
| 152 |
* already gives a pinned theme that has since been uninstalled. |
| 153 |
function wear(value) { |
| 154 |
document.documentElement.setAttribute(THEME_ATTRIBUTE, value); |
| 155 |
} |
| 156 |
|
| 157 |
|
| 158 |
* to each control at load would be gone the first time its region answered. |
| 159 |
* The same reason the described screens use htmx rather than per-element |
| 160 |
* wiring. |
| 161 |
document.addEventListener('change', (event) => { |
| 162 |
const select = event.target; |
| 163 |
if (!select || select.name !== THEME_KEY) return; |
| 164 |
|
| 165 |
* answer is the section re-read; waiting for that would put a visible |
| 166 |
* delay between the pick and the colour, for no fact the answer |
| 167 |
* carries. The store is what the next launch reads, and it is being |
| 168 |
* written either way. |
| 169 |
wear(select.value); |
| 170 |
}); |
| 171 |
|
| 172 |
document.addEventListener('click', (event) => { |
| 173 |
const element = event.target.closest('[data-sends], [data-mount]'); |
| 174 |
if (!element) return; |
| 175 |
event.preventDefault(); |
| 176 |
|
| 177 |
* asking the host to perform a call and to put its answer somewhere |
| 178 |
* else, and performing it is the half this app knows how to do. |
| 179 |
if (element.hasAttribute('data-sends')) { |
| 180 |
perform(element); |
| 181 |
} else { |
| 182 |
mount(element); |
| 183 |
} |
| 184 |
}); |
| 185 |
})(); |
| 186 |
|