Skip to main content

max / goingson

Take makeover 0.12: empty states are stand-ins, and deletes ask first
Author: Max Johnson <me@maxj.phd> · 2026-08-09 16:29 UTC
Signed with PGP, not checked
Commit: ac39d07fa7effabade8bb16470c4955d827ea9e7
Parent: c14756e
8 files changed, +279 insertions, -85 deletions
M Cargo.lock +10 -10
@@ -2174,7 +2174,7 @@
2174 2174 "makeover",
2175 2175 "makeover-build",
2176 2176 "makeover-geometry",
2177 - "makeover-layout 0.11.0",
2177 + "makeover-layout 0.12.0",
2178 2178 "makeover-webview",
2179 2179 "notify",
2180 2180 "notify-debouncer-mini",
@@ -3285,9 +3285,9 @@
3285 3285
3286 3286 [[package]]
3287 3287 name = "makeover-build"
3288 - version = "0.12.0"
3288 + version = "0.13.0"
3289 3289 source = "registry+https://github.com/rust-lang/crates.io-index"
3290 - checksum = "1a7b70edf04f3440bf01f2de7d7846cb7301c32e9d00276a33fd92548bbc34f9"
3290 + checksum = "3418e4a7f006cd1c67246114de037ccc3778db5e7357e005d3ad586ed5b6c83c"
3291 3291 dependencies = [
3292 3292 "makeover",
3293 3293 "makeover-geometry",
@@ -3314,9 +3314,9 @@
3314 3314
3315 3315 [[package]]
3316 3316 name = "makeover-layout"
3317 - version = "0.11.0"
3317 + version = "0.12.0"
3318 3318 source = "registry+https://github.com/rust-lang/crates.io-index"
3319 - checksum = "67da5deacb4c162878a5ed87839c1a08a7b1f473901f3a8ba1ae03b6ea3952a4"
3319 + checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
3320 3320
3321 3321 [[package]]
3322 3322 name = "makeover-touch"
@@ -3341,12 +3341,12 @@
3341 3341
3342 3342 [[package]]
3343 3343 name = "makeover-webview"
3344 - version = "0.20.0"
3344 + version = "0.21.0"
3345 3345 source = "registry+https://github.com/rust-lang/crates.io-index"
3346 - checksum = "7a7917e386d40de17c282f4a4354ca73172ae0a00953f0253d136270c6fb7763"
3346 + checksum = "e8d99bc16a534d241a1993e5c5093a2aadafd8ff89f3f4d4a0ebad2746a49651"
3347 3347 dependencies = [
3348 3348 "makeover-geometry",
3349 - "makeover-layout 0.11.0",
3349 + "makeover-layout 0.12.0",
3350 3350 "makeover-touch",
3351 3351 ]
3352 3352
@@ -4543,7 +4543,7 @@
4543 4543 name = "quasi-router"
4544 4544 version = "0.1.0"
4545 4545 dependencies = [
4546 - "makeover-layout 0.11.0",
4546 + "makeover-layout 0.12.0",
4547 4547 ]
4548 4548
4549 4549 [[package]]
@@ -4561,7 +4561,7 @@
4561 4561 version = "0.1.0"
4562 4562 dependencies = [
4563 4563 "docengine",
4564 - "makeover-layout 0.11.0",
4564 + "makeover-layout 0.12.0",
4565 4565 "makeover-webview",
4566 4566 "quasi-http",
4567 4567 "quasi-router",
@@ -16,12 +16,12 @@
16 16 [build-dependencies]
17 17 tauri-build = { workspace = true }
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css.
19 - makeover-build = "0.12"
19 + makeover-build = "0.13"
20 20 # The table CSS is generated here too: the columns are this app's, so the
21 21 # shared helper cannot know them, but the tracks and the narrowing rules come
22 22 # from the description rather than from hand-written nth-child cuts.
23 - makeover-webview = "0.20.0"
24 - makeover-layout = "0.11.0"
23 + makeover-webview = "0.21.0"
24 + makeover-layout = "0.12.0"
25 25 # Width. Direct rather than through makeover-webview, because the narrow table
26 26 # pass keys off SizeClass::Compact and a boundary reached transitively is a
27 27 # boundary nobody pinned.
@@ -110,8 +110,8 @@
110 110 # The forms emitter and the description it renders. Runtime deps, not build:
111 111 # form markup depends on the value, the error and the options at the moment a
112 112 # modal opens, so it cannot be materialised the way the stylesheet is.
113 - makeover-webview = "0.20.0"
114 - makeover-layout = "0.11.0"
113 + makeover-webview = "0.21.0"
114 + makeover-layout = "0.12.0"
115 115 # Browser opening
116 116 open = { workspace = true }
117 117
M src-tauri/build.rs +18 -18
@@ -17,45 +17,45 @@
17 17 const TASK_COLUMNS: &[Column<'static>] = &[
18 18 // Without it the row does not identify itself.
19 19 Column {
20 - name: "description",
21 20 width: Width::Fill,
22 21 priority: Priority::Essential,
22 + ..Column::new("description")
23 23 },
24 24 Column {
25 - name: "project",
26 25 width: Width::Fixed,
27 26 priority: Priority::Secondary,
27 + ..Column::new("project")
28 28 },
29 29 // A single letter, and the row already carries its priority in the left
30 30 // border colour, so it is the first thing that can go.
31 31 Column {
32 - name: "priority",
33 32 width: Width::Fixed,
34 33 priority: Priority::Optional,
34 + ..Column::new("priority")
35 35 },
36 36 Column {
37 - name: "due",
38 37 width: Width::Fixed,
39 38 priority: Priority::Secondary,
39 + ..Column::new("due")
40 40 },
41 41 Column {
42 - name: "recurrence",
43 42 width: Width::Fixed,
44 43 priority: Priority::Optional,
44 + ..Column::new("recurrence")
45 45 },
46 46 Column {
47 - name: "progress",
48 47 width: Width::Fixed,
49 48 priority: Priority::Optional,
49 + ..Column::new("progress")
50 50 },
51 51 // Secondary, unlike every other table's actions column: on a narrow screen
52 52 // the task row reflows into a card, and the kebab is the only affordance it
53 53 // has left. The card orders it fourth and the touch rules make it always
54 54 // visible, so dropping it here would contradict both.
55 55 Column {
56 - name: "actions",
57 56 width: Width::Fixed,
58 57 priority: Priority::Secondary,
58 + ..Column::new("actions")
59 59 },
60 60 ];
61 61
@@ -69,34 +69,34 @@
69 69 const UPCOMING_COLUMNS: &[Column<'static>] = &[
70 70 // Bulk selection. Goes first on a narrow screen, like the kebab does.
71 71 Column {
72 - name: "select",
73 72 width: Width::Fixed,
74 73 priority: Priority::Optional,
74 + ..Column::new("select")
75 75 },
76 76 Column {
77 - name: "date",
78 77 width: Width::Fixed,
79 78 priority: Priority::Essential,
79 + ..Column::new("date")
80 80 },
81 81 Column {
82 - name: "time",
83 82 width: Width::Fixed,
84 83 priority: Priority::Secondary,
84 + ..Column::new("time")
85 85 },
86 86 Column {
87 - name: "title",
88 87 width: Width::Fill,
89 88 priority: Priority::Essential,
89 + ..Column::new("title")
90 90 },
91 91 Column {
92 - name: "location",
93 92 width: Width::Fixed,
94 93 priority: Priority::Optional,
94 + ..Column::new("location")
95 95 },
96 96 Column {
97 - name: "actions",
98 97 width: Width::Fixed,
99 98 priority: Priority::Optional,
99 + ..Column::new("actions")
100 100 },
101 101 ];
102 102
@@ -107,29 +107,29 @@
107 107 /// description could never have served both.
108 108 const RECURRING_COLUMNS: &[Column<'static>] = &[
109 109 Column {
110 - name: "pattern",
111 110 width: Width::Fixed,
112 111 priority: Priority::Essential,
112 + ..Column::new("pattern")
113 113 },
114 114 Column {
115 - name: "time",
116 115 width: Width::Fixed,
117 116 priority: Priority::Secondary,
117 + ..Column::new("time")
118 118 },
119 119 Column {
120 - name: "title",
121 120 width: Width::Fill,
122 121 priority: Priority::Essential,
122 + ..Column::new("title")
123 123 },
124 124 Column {
125 - name: "location",
126 125 width: Width::Fixed,
127 126 priority: Priority::Optional,
127 + ..Column::new("location")
128 128 },
129 129 Column {
130 - name: "actions",
131 130 width: Width::Fixed,
132 131 priority: Priority::Optional,
132 + ..Column::new("actions")
133 133 },
134 134 ];
135 135
@@ -221,7 +221,7 @@
221 221 Ok(Screen::list_detail("Contacts", false)
222 222 .with(band)
223 223 .with(Slot::new("contacts-grid", RegionKind::Pane).with(grid(state, search, tag)?))
224 - .with(Slot::new("contacts-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
224 + .with(Slot::new("contacts-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
225 225 .into())
226 226 }
227 227
@@ -182,7 +182,13 @@
182 182 .map_err(|error| RouteError::internal(error.to_string()))?;
183 183
184 184 if all.is_empty() {
185 - return Ok(Node::text("No projects yet."));
185 + // The one empty state in the app with a way out of it, which is what
186 + // `Node::StandIn`'s optional act is for: 2 of goingson's 27 offer one
187 + // and 25 say a sentence and stop. `projects.js` draws the same button.
188 + return Ok(Node::empty("No projects yet.").offering(Act::new(
189 + "Create your first project",
190 + filtered(Action::get("/projects/new"), shared_only, show_retired),
191 + )));
186 192 }
187 193
188 194 let scoped: Vec<&Project> = all
@@ -191,7 +197,7 @@
191 197 .collect();
192 198
193 199 if scoped.is_empty() {
194 - return Ok(Node::text(
200 + return Ok(Node::empty(
195 201 "No shared projects yet. Share a project from its menu to see it here.",
196 202 ));
197 203 }
@@ -200,7 +206,7 @@
200 206 scoped.into_iter().partition(|project| !retired(project));
201 207
202 208 if live.is_empty() && !show_retired {
203 - return Ok(Node::text("Every project is completed or archived."));
209 + return Ok(Node::empty("Every project is completed or archived."));
204 210 }
205 211
206 212 let shown = if show_retired {
@@ -299,7 +305,7 @@
299 305 shared_only,
300 306 show_retired,
301 307 )?))
302 - .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected"))))
308 + .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))))
303 309 }
304 310
305 311 /// The whole screen.
@@ -361,7 +367,8 @@
361 367 show_retired,
362 368 ),
363 369 )
364 - .tone(makeover_layout::Tone::Danger),
370 + .tone(makeover_layout::Tone::Danger)
371 + .confirm("Are you sure you want to delete this project? This cannot be undone."),
365 372 ));
366 373
367 374 Ok(Response::fragment("projects-detail", Node::Region(slot)))
@@ -493,9 +493,14 @@
493 493 Action::post(format!("/tasks/{id}/complete")),
494 494 ));
495 495 }
496 + // The confirmation is the description's now, as of `524a63fe`. It was the
497 + // JS's — `confirmDelete` at 17 call sites — so the described screen deleted
498 + // without asking where the shipped one asks, which is the described screen
499 + // being worse than what it replaces.
496 500 band = band.with(Node::Act(
497 501 Act::new("Delete", Action::post(format!("/tasks/{id}/delete")))
498 - .tone(makeover_layout::Tone::Danger),
502 + .tone(makeover_layout::Tone::Danger)
503 + .confirm("Are you sure you want to delete this task? This cannot be undone."),
499 504 ));
500 505
501 506 let mut pane = Slot::new("task-overview", RegionKind::Pane);
@@ -40,7 +40,7 @@
40 40 use goingson_core::{
41 41 Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
42 42 };
43 - use quasi_router::screen::{Act, Meter, Row, Tag};
43 + use quasi_router::screen::{Act, Field, Meter, Row, Tag};
44 44 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
45 45
46 46 use super::{filtered_by, project_id, status_tone, type_label};
@@ -84,13 +84,13 @@
84 84 /// keeping: nothing linked yet is a different thing from everything being done.
85 85 fn tasks_column(tasks: &[Task]) -> Node {
86 86 if tasks.is_empty() {
87 - return Node::text("No tasks linked yet.");
87 + return Node::empty("No tasks linked yet.");
88 88 }
89 89 if tasks
90 90 .iter()
91 91 .all(|task| task.status == TaskStatus::Completed)
92 92 {
93 - return Node::text("All tasks complete.");
93 + return Node::empty("All tasks complete.");
94 94 }
95 95 Node::list(tasks.iter().map(task_row))
96 96 }
@@ -98,7 +98,7 @@
98 98 /// The events column.
99 99 fn events_column(events: &[Event]) -> Node {
100 100 if events.is_empty() {
101 - return Node::text("No events linked yet.");
101 + return Node::empty("No events linked yet.");
102 102 }
103 103 Node::list(events.iter().map(|event| {
104 104 let at = event.start_time.with_timezone(&Local);
@@ -109,7 +109,7 @@
109 109 /// The emails column.
110 110 fn emails_column(emails: &[Email]) -> Node {
111 111 if emails.is_empty() {
112 - return Node::text("No emails linked yet.");
112 + return Node::empty("No emails linked yet.");
113 113 }
114 114 Node::list(emails.iter().map(|email| {
115 115 let mut row = Row::new(&email.subject)
@@ -138,42 +138,79 @@
138 138 }
139 139 }
140 140
141 - /// The attachments column.
141 + /// The attachments column, both its controls included.
142 142 ///
143 - /// # The finding this port turned up
143 + /// # The finding this port turned up, and how it closed
144 144 ///
145 - /// **An action that opens a native dialog is not a route, and `Destination` has
146 - /// nowhere to put it.** The empty state carries an "Attach File" button, and it
147 - /// calls `attachments.pickAndAttach`, which opens the OS file picker. That is
148 - /// neither of `Destination`'s two members: it is not a route this app answers,
149 - /// and it is not an external address a browser navigates to. It is a capability
150 - /// of the host, asked for by name.
145 + /// **An action that opens a native dialog is not a route, and `Destination` had
146 + /// nowhere to put it.** The empty state carries an "Attach File" button calling
147 + /// `attachments.pickAndAttach`, which opens the OS file picker, and each row
148 + /// carries an open calling `attachments.openPanel`, which hands the blob to the
149 + /// OS. Neither was one of `Destination`'s two members, so this column shipped
150 + /// read-only with both controls left out rather than pointed at routes that
151 + /// could not exist.
151 152 ///
152 - /// The same is true of opening one: `attachments.openPanel` hands the blob to
153 - /// the OS. So this column is read-only in the description, and both controls are
154 - /// left out rather than pointed at routes that cannot exist.
153 + /// Filed as quasicoherent `844b5ae0` and closed 2026-08-09 by splitting it,
154 + /// which is what turned one member satisfying neither half into two answers:
155 155 ///
156 - /// This is a different shape from the other findings and may not be a gap at
157 - /// all. `Region::Bespoke` already says "the app owns what goes here" for a
158 - /// region; the honest reading may be that a host capability is the app's
159 - /// business the same way, and a description that named file pickers would be
160 - /// naming one host's abilities in a crate defined by not doing that. What argues
161 - /// the other way is that every host has *some* answer — a TUI has a path prompt,
162 - /// a server has an upload — which is the usual test for whether something
163 - /// belongs in the vocabulary.
156 + /// - *Opening* is a one-way handoff and needed no new API. The route spools the
157 + /// blob out under its own filename and answers
158 + /// `Response::goto(Action::external("file://…"))`, which the webview host
159 + /// sends as `HX-Redirect` and every other host reads as "leave".
160 + /// - *Picking* returns a value into a write, which is a form concern, so it is
161 + /// `FieldKind::File` in makeover-layout 0.11.0 — a native picker in Tauri, an
162 + /// `<input type="file">` on a server, a path prompt in a terminal.
164 163 ///
165 - /// Filed as quasicoherent `844b5ae0`, on the router rather than the
166 - /// vocabulary, for the same reason the goto finding went there: it is about what
167 - /// an action can reach, not about what is on screen.
168 - fn attachments_column(attachments: &[Attachment]) -> Node {
169 - if attachments.is_empty() {
170 - return Node::text("No attachments yet.");
164 + /// # What the port had to build rather than describe
165 + ///
166 + /// The description was the smaller half. Picking a file says nothing about
167 + /// where the bytes go, so this column needed `POST /projects/{id}/attachments`
168 + /// to exist at all, and that is why it did not land with the rest of the
169 + /// cascade. The handler is [`attach`], and the work is
170 + /// `commands::attachment::attach_path`, lifted out of the Tauri command so
171 + /// there is one copy of the hashing, the dedup and the size limit rather than
172 + /// two.
173 + ///
174 + /// # The transport half is the host's, and is not settled
175 + ///
176 + /// A `FieldKind::File` submits differently on every host, and the two the app
177 + /// has today disagree: Tauri can hand over an absolute path, and a browser
178 + /// cannot — it sends bytes as `multipart/form-data`, which `quasi-http`
179 + /// deliberately does not read (a description has no word for a byte stream).
180 + /// This route reads a path under `file`, which is what the Tauri host can
181 + /// supply and what the tests supply. Serving the same screen over HTTP would
182 + /// need the upload read somewhere that is not `Params`, and that is a host
183 + /// question rather than a description one.
184 + ///
185 + /// `error` is what a refused attach carries back, and it goes on the field the
186 + /// file came from.
187 + fn attachments_column(project: ProjectId, attachments: &[Attachment], error: Option<&str>) -> Slot {
188 + let mut field = Field::new(makeover_layout::FieldKind::File, "file", "File").required();
189 + if let Some(message) = error {
190 + field = field.error(message);
171 191 }
172 - Node::list(
173 - attachments
174 - .iter()
175 - .map(|file| Row::new(&file.filename).meta(file_size(file.file_size))),
176 - )
192 +
193 + let body = if attachments.is_empty() {
194 + Node::empty("No attachments yet.")
195 + } else {
196 + Node::list(attachments.iter().map(|file| {
197 + Row::new(&file.filename)
198 + .meta(file_size(file.file_size))
199 + .act(Act::new(
200 + "Open",
201 + Action::get(format!("/projects/{project}/attachments/{}/open", file.id)),
202 + ))
203 + }))
204 + };
205 +
206 + Slot::new("dashboard-attachments", RegionKind::Pane)
207 + .with(Node::section("Attachments"))
208 + .with(body)
209 + .with(Node::Form {
210 + action: Action::post(format!("/projects/{project}/attachments")),
211 + submit: "Attach file".to_owned(),
212 + fields: vec![field],
213 + })
177 214 }
178 215
179 216 /// How far along one milestone is, and how that reads.
@@ -265,7 +302,7 @@
265 302
266 303 let mut out = vec![Node::section("Milestones")];
267 304 if all.is_empty() {
268 - out.push(Node::text("No milestones yet"));
305 + out.push(Node::empty("No milestones yet"));
269 306 return out;
270 307 }
271 308
@@ -328,7 +365,15 @@
328 365 }
329 366
330 367 /// The whole dashboard.
331 - fn screen(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Screen, RouteError> {
368 + ///
369 + /// `attach_error` is what a refused attach carries back into the column it was
370 + /// refused in. Every other caller passes `None`.
371 + fn screen(
372 + state: &AppState,
373 + id: ProjectId,
374 + show_completed: bool,
375 + attach_error: Option<&str>,
376 + ) -> Result<Screen, RouteError> {
332 377 let project: Project = state
333 378 .projects
334 379 .get_by_id(id, DESKTOP_USER_ID)
@@ -381,11 +426,7 @@
381 426 .with(column("dashboard-tasks", "Tasks", tasks_column(&tasks)))
382 427 .with(column("dashboard-events", "Events", events_column(&events)))
383 428 .with(column("dashboard-emails", "Emails", emails_column(&emails)))
384 - .with(column(
385 - "dashboard-attachments",
386 - "Attachments",
387 - attachments_column(&files),
388 - )))
429 + .with(attachments_column(id, &files, attach_error)))
389 430 }
390 431
391 432 /// Whether a param is on.
@@ -396,12 +437,122 @@
396 437 /// The dashboard.
397 438 fn dashboard(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
398 439 let id = project_id(&params)?;
399 - Ok(screen(state, id, flag(&params, "completed"))?.into())
440 + Ok(screen(state, id, flag(&params, "completed"), None)?.into())
400 441 }
401 442
402 443 /// Answer a write with the dashboard it happened on, re-read.
403 444 fn wrote(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Response, RouteError> {
404 - Ok(screen(state, id, show_completed)?.into())
445 + Ok(screen(state, id, show_completed, None)?.into())
446 + }
447 +
448 + /// The attachments column alone, re-read.
449 + ///
450 + /// What attaching answers with, refused or not. A milestone write reflows the
451 + /// document because it lands in two places at once; attaching lands in one, so
452 + /// it takes decision 7's narrow swap and leaves the rest of the screen — and a
453 + /// half-expanded milestones section — where it was.
454 + fn attachments_pane(
455 + state: &AppState,
456 + id: ProjectId,
457 + error: Option<&str>,
458 + ) -> Result<Response, RouteError> {
459 + let files = state
460 + .attachments
461 + .list_for_project(id, DESKTOP_USER_ID)
462 + .map_err(|error| RouteError::internal(error.to_string()))?;
463 + Ok(Response::fragment(
464 + "dashboard-attachments",
465 + Node::Region(attachments_column(id, &files, error)),
466 + ))
467 + }
468 +
469 + /// Attach the picked file to this project.
470 + ///
471 + /// The path arrives under `file`, which is the name the `FieldKind::File` field
472 + /// submits under. See [`attachments_column`] for why it is a path and what that
473 + /// leaves open.
474 + fn attach(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
475 + let id = project_id(&params)?;
476 + let picked = params.get("file").unwrap_or_default().trim().to_owned();
477 + if picked.is_empty() {
478 + return attachments_pane(state, id, Some("Choose a file to attach."));
479 + }
480 +
481 + match crate::commands::attachment::attach_path(state, None, Some(id), &picked) {
482 + Ok(attachment) => Ok(attachments_pane(state, id, None)?.toast(
483 + makeover_layout::Tone::Success,
484 + format!("Attached {}.", attachment.filename),
485 + )),
486 + // A refusal is the user's to fix by picking another file, so it goes
487 + // back on the field. A failure is ours and is not something a form can
488 + // say anything useful about.
489 + Err(crate::commands::attachment::AttachFailure::Refused(message)) => {
490 + attachments_pane(state, id, Some(&message))
491 + }
492 + Err(crate::commands::attachment::AttachFailure::Failed(message)) => {
493 + Err(RouteError::internal(message))
494 + }
495 + }
496 + }
497 +
498 + /// The attachment a route was addressed at.
499 + fn attachment_id(params: &quasi_router::Params) -> Result<goingson_core::AttachmentId, RouteError> {
500 + let raw = params
501 + .get("attachment")
502 + .ok_or_else(|| RouteError::not_found("no attachment id"))?;
503 + Ok(goingson_core::AttachmentId::from(
504 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an attachment id"))?,
505 + ))
506 + }
507 +
508 + /// Hand one attachment to whatever the host opens files with.
509 + ///
510 + /// A `GET`, because nothing about the project changes: the spool copy is how a
511 + /// content-addressed blob is read under the name it was attached with, not a
512 + /// write the user made.
513 + fn open(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
514 + let id = attachment_id(&params)?;
515 + let attachment = state
516 + .attachments
517 + .get_by_id(id, DESKTOP_USER_ID)
518 + .map_err(|error| RouteError::internal(error.to_string()))?
519 + .ok_or_else(|| RouteError::not_found("no such attachment"))?;
520 +
521 + let spooled = crate::commands::attachment::spool(
522 + &state.data_dir,
523 + &attachment.blob_hash,
524 + &attachment.filename,
525 + )
526 + .map_err(|failure| match failure {
527 + // Not there yet rather than not there at all: an unsynced blob is a
528 + // thing the user can wait for, and the message says which it is.
529 + crate::commands::attachment::AttachFailure::Refused(message) => {
530 + RouteError::not_found(message)
531 + }
532 + crate::commands::attachment::AttachFailure::Failed(message) => {
533 + RouteError::internal(message)
534 + }
535 + })?;
536 +
537 + Ok(Response::goto(Action::external(file_url(&spooled))))
538 + }
539 +
540 + /// A `file://` address for a path on this machine.
541 + ///
542 + /// Percent-encoded by hand rather than by a crate: one call site, and the whole
543 + /// rule is that everything outside the unreserved set goes out as `%XX` with
544 + /// the separator kept. A filename with a space or a `#` in it is the common
545 + /// case this exists for, and both would otherwise truncate the address.
546 + fn file_url(path: &std::path::Path) -> String {
547 + let mut url = String::from("file://");
548 + for byte in path.to_string_lossy().bytes() {
549 + match byte {
550 + b'/' | b'-' | b'.' | b'_' | b'~' => url.push(byte as char),
551 + _ if byte.is_ascii_alphanumeric() => url.push(byte as char),
552 + _ => url.push_str(&format!("%{byte:02X}")),
553 + }
554 + }
555 + url
405 556 }
406 557
407 558 /// The milestone a route was addressed at.
@@ -482,4 +633,6 @@
482 633 "/projects/:id/milestones/:milestone/delete",
483 634 delete_milestone,
484 635 )
636 + .post("/projects/:id/attachments", attach)
637 + .get("/projects/:id/attachments/:attachment/open", open)
485 638 }
@@ -86,13 +86,42 @@
86 86 }
87 87
88 88 #[tokio::test]
89 - async fn an_empty_database_says_so_rather_than_rendering_nothing() {
89 + async fn an_empty_database_says_so_as_a_stand_in_with_a_way_out() {
90 + // `703f4cd2`. This asserted a bare line of text until 2026-08-09, because
91 + // nothing named an empty state and the description had only `Node::text` to
92 + // say it with. `Node::StandIn` is what changed.
90 93 let state = state().await;
91 94 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
92 95 panic!("the index answers with a screen");
93 96 };
94 97 let html = quasi_webview::Webview::new().screen(&screen);
98 +
95 99 assert!(html.contains("No projects yet."));
100 + assert!(html.contains(r#"data-state="empty""#));
101 + // Not a fault: an empty database is the normal state of a new install.
102 + assert!(!html.contains(r#"role="alert""#));
103 + // The way out, which is the half `Node::text` could never carry.
104 + assert!(html.contains("Create your first project"));
105 + assert!(html.contains("hx-get=\"/projects/new"));
106 + }
107 +
108 + #[tokio::test]
109 + async fn deleting_a_project_asks_first() {
110 + // `524a63fe`. The JS confirms through `confirmDelete` and the described
111 + // screen did not, so the description was worse than what it replaces on
112 + // the one path where that matters most.
113 + let state = state().await;
114 + let project = add(&state, "Doomed", ProjectStatus::Active);
115 +
116 + let Outcome::Fragment { node, .. } =
117 + answer(&state, &format!("/projects/{}", project.id), Params::new()).outcome
118 + else {
119 + panic!("the detail pane answers with a fragment");
120 + };
121 + let html = quasi_webview::Webview::new().fragment(&node);
122 +
123 + assert!(html.contains("Delete project"));
124 + assert!(html.contains("hx-confirm=\"Are you sure you want to delete this project?"));
96 125 }
97 126
98 127 #[tokio::test]