/* * The host half of `Action::by_host`. * * quasicoherent `a81384d4` ruled that some calls are the host's rather than the * renderer's, and quasi-webview's answer is to emit `data-sends=""` with no * transport at all: no verb, no trigger, no href. Something has to act on that, * and on this app that something is this file. * * WHY THIS FILE EXISTS AT ALL, since the 2026-08-22 swap deleted 84 scripts and * the whole point was that screens are described. Because picking a file is not * describable and was never going to be. A described `FieldKind::File` renders * ``, htmx submits it urlencoded, and a browser reports a * masked filename rather than a path; multipart is refused outright by * `quasi_http::is_form`, on the grounds that a description has no word for a * byte stream. So the field could deliver neither bytes nor a path, and the * three imports and the project attach were quietly broken from the moment the * described screens started serving. * * What used to work was the JavaScript calling `window.__TAURI__.dialog.open()` * and handing the command an absolute path. That is exactly the "host that * already knows the chain" the ruling describes, and this is it, kept to the one * job. * * WHAT IT IS NOT. Not a place for behaviour that belongs in a description. If * something here grows past picking a file and handing back what the host got, * that is a described screen wearing a script, and the thing to do is describe * it. * * THE SECOND JOB, added 2026-08-22, is `data-mount` and it is the same job in a * different coat: a webview cannot put up a window any more than it can open a * file dialog, so the description says where and this does it. What makes it * not the thing the paragraph above forbids is that it answers a word -- * `Action::elsewhere`, quasi 0.55.0 -- rather than inventing a behaviour. Max * ruled that (goingson `3fb2526a`), against two cheaper options that would each * have put the second window outside the description: a bespoke branch here, or * a menu item no screen can see. * * THE THIRD JOB, added 2026-08-30, is the theme picker, and it passes the same * test. The described word is `FieldKind::Theme`, and reaching the root of a * document that is already open is genuinely impossible for the renderer: the * answer to the write is swapped in by htmx, which parses the response's * `` away and never touches `document.documentElement`. So the sheet * carries every theme keyed by `data-theme` (see `src/quasi/theming.rs`) and * this sets the attribute. * * The test for a fourth job is the same one: is there a described word it is * answering, and is the thing it does genuinely impossible for a renderer? */ (() => { /* The dialog filter, from the accept list the description carried. * * `data-vals` holds the action's own params, which is where the accept list * travels: the description says what it will take, and turning that into a * platform file dialog's filter is the host's business. */ function filters(vals) { if (!vals.accept) return undefined; const extensions = vals.accept .split(',') .map((one) => one.trim().replace(/^\./, '')) .filter(Boolean); if (extensions.length === 0) return undefined; return [{ name: vals.name || 'Files', extensions }]; } /* Perform one host call: ask for a file, then send its path where the * description said. * * The send goes through `htmx.ajax` rather than `fetch`, because the answer * is an ordinary fragment with the ordinary swap headers on it. Doing the * request by hand would mean reimplementing retarget, reswap and * out-of-band swaps, and getting one of them subtly wrong. */ async function perform(element) { const url = element.getAttribute('data-sends'); if (!url) return; let vals = {}; try { vals = JSON.parse(element.getAttribute('data-vals') || '{}'); } catch (_) { /* A malformed attribute is this app disagreeing with itself. Send * what we can rather than dropping the whole interaction. */ } const dialog = window.__TAURI__ && window.__TAURI__.dialog; if (!dialog) { /* No host to ask. Said out loud rather than silently doing nothing, * because a control that looks pressable and is not is the failure * this whole file is fixing. */ console.error('[host] no Tauri dialog available; cannot pick a file'); return; } const picked = await dialog.open({ multiple: false, filters: filters(vals) }); if (!picked) return; // Cancelled. window.htmx.ajax('POST', url, { source: element, values: Object.assign({}, vals, { file: picked }), }); } /* Put this address up in a mount of its own, which here is a window. * * `quasi-webview` emits `data-mount` with no transport at all -- no verb, * no trigger, no href -- for the same reason it does that for `data-sends`: * every one of them would put the answer in the mount the control is * already in, which is what the mark says not to do. * * The command takes the path and checks it before opening anything. This * file deliberately does no checking of its own: two answers to which * addresses may become a window is one answer too many, and the one that * matters is the one in Rust. */ async function mount(element) { const path = element.getAttribute('data-mount'); if (!path) return; const core = window.__TAURI__ && window.__TAURI__.core; if (!core) { /* Said out loud rather than silently doing nothing, for the reason * the missing dialog is: a control that looks pressable and is not * is the failure this whole file is fixing. */ console.error('[host] no Tauri core available; cannot open a window'); return; } try { await core.invoke('open_compose_window', { path }); } catch (error) { console.error('[host] could not open a window on', path, error); } } /* The root attribute makeover keys its per-theme blocks on: its own * `makeover::THEME_ATTRIBUTE`, which the sheet is emitted from, so a change * there is a change here. * * The value is whatever the picker carries and is passed through unread, * Follow System included -- that choice has a keyed block of its own, so * there is never a reason to remove the attribute. Removing it would fall * back to the unkeyed blocks, and those are whatever was pinned when the * app started. */ const THEME_ATTRIBUTE = 'data-theme'; /* The key the picker writes, which is this app's own: the select is named * for the `user_config` row behind it. See `quasi::settings`. */ const THEME_KEY = 'theme'; /* Apply a theme to the open document. * * Nothing is validated. A value naming no theme matches no keyed block and * the unkeyed ones stay in force, which is the same fallback the sheet * already gives a pinned theme that has since been uninstalled. */ function wear(value) { document.documentElement.setAttribute(THEME_ATTRIBUTE, value); } /* Delegated, because a fragment swap replaces the elements: a listener bound * to each control at load would be gone the first time its region answered. * The same reason the described screens use htmx rather than per-element * wiring. */ document.addEventListener('change', (event) => { const select = event.target; if (!select || select.name !== THEME_KEY) return; /* Before the write, not after it. The request is in flight and its * answer is the section re-read; waiting for that would put a visible * delay between the pick and the colour, for no fact the answer * carries. The store is what the next launch reads, and it is being * written either way. */ wear(select.value); }); document.addEventListener('click', (event) => { const element = event.target.closest('[data-sends], [data-mount]'); if (!element) return; event.preventDefault(); /* `data-sends` first. A control carrying both would be a description * asking the host to perform a call and to put its answer somewhere * else, and performing it is the half this app knows how to do. */ if (element.hasAttribute('data-sends')) { perform(element); } else { mount(element); } }); })();