| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
437 |
|
/// The dashboard.
|
| 397 |
438 |
|
fn dashboard(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
| 398 |
439 |
|
let id = project_id(¶ms)?;
|
| 399 |
|
- |
Ok(screen(state, id, flag(¶ms, "completed"))?.into())
|
|
440 |
+ |
Ok(screen(state, id, flag(¶ms, "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(¶ms)?;
|
|
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(¶ms)?;
|
|
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 |
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 |
|
}
|