max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | })(); |