Skip to main content

max / goingson

8.6 KB · 186 lines History Blame Raw
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 /* The dialog filter, from the accept list the description carried.
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 /* Perform one host call: ask for a file, then send its path where the
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 /* A malformed attribute is this app disagreeing with itself. Send
81 * what we can rather than dropping the whole interaction. */
82 }
83
84 const dialog = window.__TAURI__ && window.__TAURI__.dialog;
85 if (!dialog) {
86 /* No host to ask. Said out loud rather than silently doing nothing,
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; // Cancelled.
95
96 window.htmx.ajax('POST', url, {
97 source: element,
98 values: Object.assign({}, vals, { file: picked }),
99 });
100 }
101
102 /* Put this address up in a mount of its own, which here is a window.
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 /* Said out loud rather than silently doing nothing, for the reason
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 /* The root attribute makeover keys its per-theme blocks on: its own
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 /* The key the picker writes, which is this app's own: the select is named
145 * for the `user_config` row behind it. See `quasi::settings`. */
146 const THEME_KEY = 'theme';
147
148 /* Apply a theme to the open document.
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 /* Delegated, because a fragment swap replaces the elements: a listener bound
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 /* Before the write, not after it. The request is in flight and its
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 /* `data-sends` first. A control carrying both would be a description
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