Skip to main content

max / goingson

Picking a file is the host's call, so the imports work again THE BUG. quasi::data's three imports and the project dashboard's attach could not receive a file, and had not been able to since the described screens started serving. A FieldKind::File renders <input type="file">; htmx submits it urlencoded, where a browser reports a masked filename rather than a path; and quasi_http::is_form refuses multipart outright, on the grounds that a description has no word for a byte stream. So the field delivered neither bytes nor a path, while every handler read payload["file"] as a filesystem path. The tests could not see it. They hand the handler a real path as a param, which is a value the renderer would never send, so they passed against a screen that did not work. WHY IT LOOKED FINE. quasicoherent 3b830122 measured this in August and recorded "Tauri can hand over an absolute path, which is what POST /projects/{id}/attachments reads today", concluding one site, working, Tauri-only. What handed over a path was the JavaScript: the old screens called window.__TAURI__.dialog.open() and passed the command the result. Tauri's webview is still a webview, and the described field never went through the dialog at all. That premise is corrected on the task. THE FIX is the mechanism already ruled for it. Action::by_host (a81384d4, "that the host makes this call, and the renderer does not") emits data-sends with no transport: the address is the half the description knows, and how the file gets there is the host's. The controls are acts carrying it now, with the accept list travelling as a param so a native dialog knows what it is asking for. frontend/js/host.js is the host half, and the first hand-written script since the swap deleted 84 of them. It is allowed to exist for the reason the ruling gives: picking a file is not describable and was never going to be. It opens the dialog and hands the path back through htmx.ajax rather than fetch, because the answer is an ordinary fragment with ordinary swap headers and doing the request by hand would mean reimplementing retarget and reswap and getting one subtly wrong. Its header says what it is not for. The routes are unchanged: a path under `file` is still exactly what arrives. The document shell is lifted out of protocol() as document_shell(), because what the document loads is now a fact worth asserting: data-sends carries no transport, so a document without host.js draws buttons that look pressable and are not, which is this bug again in a new place. There is a test. Three tests rewritten to assert the working shape rather than the broken one, and two added: what the control emits, and that the shell loads the host half. 794 total. NOT IN THIS COMMIT. Compose still has no Attach. Its attachments want an attachments row against the draft and a drainer that resolves blobs to paths at send time, which is the right shape anyway: a queued message may go hours later and the file it was written from can be gone, while a blob cannot.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 19:17 UTC
Signed with PGP, not checked
Commit: 2cc54be4c1cae5af96fa4f4567f6c63e3d894e84
Parent: f8eab8d
7 files changed, +255 insertions, -55 deletions
@@ -44,6 +44,11 @@
44 44 (JS, include_bytes!("../../frontend/js/quasi-selection.js"))
45 45 }
46 46 "/static/quasi-clock.js" => (JS, include_bytes!("../../frontend/js/quasi-clock.js")),
47 +
48 + // The host half of `Action::by_host`: picking a file is not describable
49 + // and never was. Hand-written, and the only such file left after the
50 + // swap. See `frontend/js/host.js` for why it is allowed to exist.
51 + "/static/host.js" => (JS, include_bytes!("../../frontend/js/host.js")),
47 52 "/static/quasi-download.js" => (JS, include_bytes!("../../frontend/js/quasi-download.js")),
48 53 "/static/quasi-fill.js" => (JS, include_bytes!("../../frontend/js/quasi-fill.js")),
49 54
@@ -217,6 +217,20 @@
217 217 }
218 218 }
219 219
220 + /// The extensions this import will take, for the host's file dialog.
221 + ///
222 + /// Carried as a parameter on the action rather than as
223 + /// [`Field::accept`](quasi_router::screen::Field::accept), because there is
224 + /// no field: the control is a host-performed act, and this is what the
225 + /// description tells the host about what it is asking for.
226 + const fn accept(self) -> &'static str {
227 + match self {
228 + Self::Csv => "csv,tsv",
229 + Self::Contacts => "vcf,vcard",
230 + Self::Calendar => "ics,ical",
231 + }
232 + }
233 +
220 234 /// Standing help under the field, which is where the shipped wizard's
221 235 /// paragraph of column names belongs once there is no modal to head.
222 236 const fn hint(self) -> &'static str {
@@ -244,15 +258,18 @@
244 258
245 259 /// One import's form: pick a file, see what is in it.
246 260 fn import_form(kind: Kind) -> Node {
247 - Node::Form {
248 - action: Action::post(format!("/data/import/{}/preview", kind.slug())),
249 - submit: "Preview".to_owned(),
250 - fields: vec![
251 - Field::new(FieldKind::File, "file", kind.label())
252 - .required()
253 - .hint(kind.hint()),
254 - ],
255 - }
261 + Node::Region(
262 + Slot::group(format!("data-import-{}", kind.slug()))
263 + .with(Node::Act(Act::new(
264 + format!("Choose a {}", kind.label()),
265 + Action::post(format!("/data/import/{}/preview", kind.slug()))
266 + .with("accept", kind.accept())
267 + .with("name", kind.label())
268 + .by_host()
269 + .awaiting(),
270 + )))
271 + .with(Node::text(kind.hint())),
272 + )
256 273 }
257 274
258 275 /// The import half of the screen.
@@ -314,6 +314,35 @@
314 314 events::routes(router)
315 315 }
316 316
317 + /// The document every described screen is served inside.
318 + ///
319 + /// Lifted out of [`protocol`] so it can be looked at without a Tauri handle.
320 + /// What the document loads is a fact worth a test, and `host.js` is the reason
321 + /// it became one.
322 + #[must_use]
323 + pub fn document_shell() -> quasi_webview::Shell {
324 + quasi_webview::Shell::under("quasi://localhost/static")
325 + // The same order and the same layers index.html declared, because it is
326 + // the same cascade: geometry and typography before the sheet that reads
327 + // their custom properties.
328 + .layered(["base", "components", "responsive"])
329 + .styled("/static/typography.css")
330 + .styled("/static/geometry.css")
331 + .styled("/static/layout.css")
332 + .styled("/static/tables.css")
333 + .styled("/static/styles.css")
334 + // Not vendored, so asking for it would be one 404 per document.
335 + .without_hyperscript()
336 + // The host half of `Action::by_host`, which is how a file gets picked.
337 + // `with_head` because a `Shell` has no `script`, and this is the app's
338 + // own rather than one the renderer ships. See `assets` and
339 + // `frontend/js/host.js`.
340 + .with_head("<script src=\"/static/host.js\" defer></script>")
341 + // The app's own furniture: where you can go, and the running-timer
342 + // band. See [`shell`].
343 + .with_chrome(shell::chrome())
344 + }
345 +
317 346 /// The custom protocol serving the screens inside the app, and the handle its
318 347 /// state arrives through.
319 348 ///
@@ -336,24 +365,7 @@
336 365 "quasi",
337 366 router(),
338 367 Arc::new(
339 - quasi_webview::Webview::under("quasi://localhost/static").with_shell(
340 - quasi_webview::Shell::under("quasi://localhost/static")
341 - // The same order and the same layers index.html declares,
342 - // because it is the same cascade: geometry and typography
343 - // before the sheet that reads their custom properties.
344 - .layered(["base", "components", "responsive"])
345 - .styled("/static/typography.css")
346 - .styled("/static/geometry.css")
347 - .styled("/static/layout.css")
348 - .styled("/static/tables.css")
349 - .styled("/static/styles.css")
350 - // Not vendored, so asking for it would be one 404 per
351 - // document. Nothing described here uses it yet.
352 - .without_hyperscript()
353 - // The app's own furniture: where you can go, and the
354 - // running-timer band. See [`shell`].
355 - .with_chrome(shell::chrome()),
356 - ),
368 + quasi_webview::Webview::under("quasi://localhost/static").with_shell(document_shell()),
357 369 ),
358 370 );
359 371 // A stylesheet is not a description. This runs before the router and wins,
@@ -678,3 +678,55 @@
678 678 // ruling did not touch.
679 679 assert!(!page.contains("Create Backup"));
680 680 }
681 +
682 + /// Picking a file is the host's, and the control says so.
683 + ///
684 + /// This was a `FieldKind::File` inside a form until 2026-08-22, and it could not
685 + /// work: the field renders `<input type="file">`, htmx submits urlencoded, and a
686 + /// browser reports a masked filename rather than a path — while
687 + /// `quasi_http::is_form` refuses multipart outright, on the grounds that a
688 + /// description has no word for a byte stream. So the handler, which reads
689 + /// `payload["file"]` as a path, could never receive one. The tests did not see
690 + /// it because they hand the handler a real path, which is a value the renderer
691 + /// would never send.
692 + #[tokio::test]
693 + async fn picking_a_file_is_the_hosts_call_and_carries_no_transport() {
694 + let (state, _dir) = state().await;
695 + let markup = html(get(&state, "/data"));
696 +
697 + // No file input anywhere: the control is an act the host performs.
698 + assert!(!markup.contains("type=\"file\""), "{markup}");
699 +
700 + // `data-sends` and no verb, which is what `Action::by_host` emits: the
701 + // address is the half the description knows, and how the file gets there is
702 + // the host's.
703 + assert!(
704 + markup.contains("data-sends=\"/data/import/csv/preview\""),
705 + "{markup}"
706 + );
707 +
708 + // The accept list travels with it, because a native dialog needs to know
709 + // what it is asking for and the description is what says.
710 + assert!(markup.contains("csv,tsv"), "{markup}");
711 + assert!(markup.contains("vcf,vcard"), "{markup}");
712 + assert!(markup.contains("ics,ical"), "{markup}");
713 + }
714 +
715 + /// The host half is loaded, or none of the above does anything.
716 + ///
717 + /// `data-sends` carries no transport by design, so a document without
718 + /// `host.js` draws three buttons that look pressable and are not. That is the
719 + /// exact failure this whole change is fixing, so it is worth its own assertion
720 + /// against the shell the app actually serves.
721 + #[tokio::test]
722 + async fn the_document_loads_the_host_half() {
723 + let (state, _dir) = state().await;
724 + let response = get(&state, "/data");
725 + let quasi_router::Outcome::Screen(screen) = &response.outcome else {
726 + panic!("the data screen answers a screen");
727 + };
728 + let markup = quasi_webview::Webview::new()
729 + .with_shell(crate::quasi::document_shell())
730 + .screen(screen);
731 + assert!(markup.contains("/static/host.js"), "{markup}");
732 + }
@@ -185,25 +185,29 @@
185 185 /// there is one copy of the hashing, the dedup and the size limit rather than
186 186 /// two.
187 187 ///
188 - /// # The transport half is the host's, and is not settled
188 + /// # The transport half is the host's, and the field could not carry it
189 189 ///
190 - /// A `FieldKind::File` submits differently on every host, and the two the app
191 - /// has today disagree: Tauri can hand over an absolute path, and a browser
192 - /// cannot — it sends bytes as `multipart/form-data`, which `quasi-http`
193 - /// deliberately does not read (a description has no word for a byte stream).
194 - /// This route reads a path under `file`, which is what the Tauri host can
195 - /// supply and what the tests supply. Serving the same screen over HTTP would
196 - /// need the upload read somewhere that is not `Params`, and that is a host
197 - /// question rather than a description one.
190 + /// This used a `FieldKind::File` until 2026-08-22, on the belief recorded in
191 + /// quasicoherent `3b830122` that "Tauri can hand over an absolute path". It
192 + /// cannot, and the belief came from the JavaScript rather than the field: the
193 + /// old screen called `window.__TAURI__.dialog.open()` and passed the command an
194 + /// absolute path. A described `FieldKind::File` renders `<input type="file">`
195 + /// into the same webview a browser would use, htmx submits it urlencoded, and a
196 + /// browser reports a masked filename. Multipart is refused outright by
197 + /// `quasi_http::is_form`. So the field delivered neither bytes nor a path, and
198 + /// this route was unreachable from the moment the described screens started
199 + /// serving — the tests never saw it because they hand the handler a real path,
200 + /// which is a value the renderer would never send.
198 201 ///
199 - /// `error` is what a refused attach carries back, and it goes on the field the
200 - /// file came from.
202 + /// It is an [`Act`] carrying [`Action::by_host`] now (`a81384d4`, "that the host
203 + /// makes this call, and the renderer does not"), and `frontend/js/host.js` is
204 + /// the host half: it opens the native dialog and posts the path here. The route
205 + /// itself is unchanged, because a path under `file` is still exactly what
206 + /// arrives.
207 + ///
208 + /// `error` is what a refused attach carries back. It is a notice beside the
209 + /// control rather than on it, since there is no longer a field to hang it on.
201 210 fn attachments_column(project: ProjectId, attachments: &[Attachment], error: Option<&str>) -> Slot {
202 - let mut field = Field::new(makeover_layout::FieldKind::File, "file", "File").required();
203 - if let Some(message) = error {
204 - field = field.error(message);
205 - }
206 -
207 211 let body = if attachments.is_empty() {
208 212 Node::empty("No attachments yet.")
209 213 } else {
@@ -220,10 +224,24 @@
220 224 Slot::new("dashboard-attachments", RegionKind::Pane)
221 225 .with(Node::section("Attachments"))
222 226 .with(body)
223 - .with(Node::Form {
224 - action: Action::post(format!("/projects/{project}/attachments")),
225 - submit: "Attach file".to_owned(),
226 - fields: vec![field],
227 + // A host-performed act rather than a `FieldKind::File` in a form. See
228 + // the header: the field could not deliver a file on this host, because
229 + // it renders `<input type="file">` and a browser reports a masked
230 + // filename rather than a path. `Action::by_host` is the ruled answer
231 + // (`a81384d4`), and `frontend/js/host.js` is the host half.
232 + .with(Node::Act(Act::new(
233 + "Attach a file",
234 + Action::post(format!("/projects/{project}/attachments"))
235 + .by_host()
236 + .awaiting(),
237 + )))
238 + .with(match error {
239 + Some(message) => Node::Notice {
240 + kind: makeover_layout::Notice::Banner,
241 + tone: makeover_layout::Tone::Danger,
242 + text: message.to_owned(),
243 + },
244 + None => Node::text(""),
227 245 })
228 246 }
229 247
@@ -399,18 +399,22 @@
399 399 }
400 400
401 401 #[tokio::test]
402 - async fn the_attachments_column_offers_a_file_field() {
403 - // The finding, closed. `attachments.pickAndAttach` opens the OS file
404 - // picker, which was neither a route this app answers nor an external
405 - // address; `FieldKind::File` is how the description asks for a file without
406 - // naming one host's way of choosing it.
402 + async fn the_attachments_column_asks_the_host_to_pick_a_file() {
403 + // `attachments.pickAndAttach` opened the OS file picker, which is neither a
404 + // route this app answers nor an external address. This was a
405 + // `FieldKind::File` until 2026-08-22 on the belief that a Tauri host could
406 + // hand back a path through one; it cannot, because the field renders
407 + // `<input type="file">` into a webview and a browser reports a masked
408 + // filename. `Action::by_host` is the ruled answer and `frontend/js/host.js`
409 + // is the half that opens the dialog.
407 410 let state = state().await;
408 411 let project = project(&state);
409 412 let page = dashboard(&state, project);
410 413
411 414 assert!(page.contains("No attachments yet."));
412 - assert!(page.contains(r#"type="file""#));
413 - assert!(page.contains("Attach file"));
415 + assert!(!page.contains(r#"type="file""#), "{page}");
416 + assert!(page.contains("data-sends="), "{page}");
417 + assert!(page.contains("Attach a file"));
414 418 }
415 419
416 420 /// A file on disk to attach, named for the test that wants it.
@@ -444,7 +448,7 @@
444 448 assert!(page.contains("5 B"));
445 449 // Still offering the field, so a second file is one click away rather than
446 450 // a reload.
447 - assert!(page.contains(r#"type="file""#));
451 + assert!(page.contains("data-sends="), "{page}");
448 452 }
449 453
450 454 #[tokio::test]
@@ -1,0 +1,92 @@
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 + (() => {
30 + /* The dialog filter, from the accept list the description carried.
31 + *
32 + * `data-vals` holds the action's own params, which is where the accept list
33 + * travels: the description says what it will take, and turning that into a
34 + * platform file dialog's filter is the host's business. */
35 + function filters(vals) {
36 + if (!vals.accept) return undefined;
37 + const extensions = vals.accept
38 + .split(',')
39 + .map((one) => one.trim().replace(/^\./, ''))
40 + .filter(Boolean);
41 + if (extensions.length === 0) return undefined;
42 + return [{ name: vals.name || 'Files', extensions }];
43 + }
44 +
45 + /* Perform one host call: ask for a file, then send its path where the
46 + * description said.
47 + *
48 + * The send goes through `htmx.ajax` rather than `fetch`, because the answer
49 + * is an ordinary fragment with the ordinary swap headers on it. Doing the
50 + * request by hand would mean reimplementing retarget, reswap and
51 + * out-of-band swaps, and getting one of them subtly wrong. */
52 + async function perform(element) {
53 + const url = element.getAttribute('data-sends');
54 + if (!url) return;
55 +
56 + let vals = {};
57 + try {
58 + vals = JSON.parse(element.getAttribute('data-vals') || '{}');
59 + } catch (_) {
60 + /* A malformed attribute is this app disagreeing with itself. Send
61 + * what we can rather than dropping the whole interaction. */
62 + }
63 +
64 + const dialog = window.__TAURI__ && window.__TAURI__.dialog;
65 + if (!dialog) {
66 + /* No host to ask. Said out loud rather than silently doing nothing,
67 + * because a control that looks pressable and is not is the failure
68 + * this whole file is fixing. */
69 + console.error('[host] no Tauri dialog available; cannot pick a file');
70 + return;
71 + }
72 +
73 + const picked = await dialog.open({ multiple: false, filters: filters(vals) });
74 + if (!picked) return; // Cancelled.
75 +
76 + window.htmx.ajax('POST', url, {
77 + source: element,
78 + values: Object.assign({}, vals, { file: picked }),
79 + });
80 + }
81 +
82 + /* Delegated, because a fragment swap replaces the elements: a listener bound
83 + * to each control at load would be gone the first time its region answered.
84 + * The same reason the described screens use htmx rather than per-element
85 + * wiring. */
86 + document.addEventListener('click', (event) => {
87 + const element = event.target.closest('[data-sends]');
88 + if (!element) return;
89 + event.preventDefault();
90 + perform(element);
91 + });
92 + })();