max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+335 insertions,
-60 deletions
| @@ -1387,7 +1387,7 @@ | |||
| 1387 | 1387 | "libc", | |
| 1388 | 1388 | "option-ext", | |
| 1389 | 1389 | "redox_users", | |
| 1390 | - | "windows-sys 0.61.2", | |
| 1390 | + | "windows-sys 0.59.0", | |
| 1391 | 1391 | ] | |
| 1392 | 1392 | ||
| 1393 | 1393 | [[package]] | |
| @@ -1628,7 +1628,7 @@ | |||
| 1628 | 1628 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1629 | 1629 | dependencies = [ | |
| 1630 | 1630 | "libc", | |
| 1631 | - | "windows-sys 0.61.2", | |
| 1631 | + | "windows-sys 0.59.0", | |
| 1632 | 1632 | ] | |
| 1633 | 1633 | ||
| 1634 | 1634 | [[package]] | |
| @@ -3639,7 +3639,7 @@ | |||
| 3639 | 3639 | "png 0.18.1", | |
| 3640 | 3640 | "serde", | |
| 3641 | 3641 | "thiserror 2.0.20", | |
| 3642 | - | "windows-sys 0.61.2", | |
| 3642 | + | "windows-sys 0.60.2", | |
| 3643 | 3643 | ] | |
| 3644 | 3644 | ||
| 3645 | 3645 | [[package]] | |
| @@ -3772,7 +3772,7 @@ | |||
| 3772 | 3772 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3773 | 3773 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 3774 | 3774 | dependencies = [ | |
| 3775 | - | "windows-sys 0.61.2", | |
| 3775 | + | "windows-sys 0.59.0", | |
| 3776 | 3776 | ] | |
| 3777 | 3777 | ||
| 3778 | 3778 | [[package]] | |
| @@ -3880,7 +3880,7 @@ | |||
| 3880 | 3880 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3881 | 3881 | checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" | |
| 3882 | 3882 | dependencies = [ | |
| 3883 | - | "proc-macro-crate 3.5.0", | |
| 3883 | + | "proc-macro-crate 1.3.1", | |
| 3884 | 3884 | "proc-macro2", | |
| 3885 | 3885 | "quote", | |
| 3886 | 3886 | "syn 2.0.119", | |
| @@ -5195,7 +5195,7 @@ | |||
| 5195 | 5195 | "errno", | |
| 5196 | 5196 | "libc", | |
| 5197 | 5197 | "linux-raw-sys", | |
| 5198 | - | "windows-sys 0.61.2", | |
| 5198 | + | "windows-sys 0.59.0", | |
| 5199 | 5199 | ] | |
| 5200 | 5200 | ||
| 5201 | 5201 | [[package]] | |
| @@ -5252,7 +5252,7 @@ | |||
| 5252 | 5252 | "security-framework", | |
| 5253 | 5253 | "security-framework-sys", | |
| 5254 | 5254 | "webpki-root-certs", | |
| 5255 | - | "windows-sys 0.61.2", | |
| 5255 | + | "windows-sys 0.59.0", | |
| 5256 | 5256 | ] | |
| 5257 | 5257 | ||
| 5258 | 5258 | [[package]] | |
| @@ -5792,7 +5792,7 @@ | |||
| 5792 | 5792 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 5793 | 5793 | dependencies = [ | |
| 5794 | 5794 | "libc", | |
| 5795 | - | "windows-sys 0.61.2", | |
| 5795 | + | "windows-sys 0.60.2", | |
| 5796 | 5796 | ] | |
| 5797 | 5797 | ||
| 5798 | 5798 | [[package]] | |
| @@ -5991,7 +5991,7 @@ | |||
| 5991 | 5991 | ||
| 5992 | 5992 | [[package]] | |
| 5993 | 5993 | name = "synckit-client" | |
| 5994 | - | version = "0.8.1" | |
| 5994 | + | version = "0.9.0" | |
| 5995 | 5995 | dependencies = [ | |
| 5996 | 5996 | "apple-native-keyring-store", | |
| 5997 | 5997 | "argon2", | |
| @@ -6564,10 +6564,10 @@ | |||
| 6564 | 6564 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 6565 | 6565 | dependencies = [ | |
| 6566 | 6566 | "fastrand", | |
| 6567 | - | "getrandom 0.4.3", | |
| 6567 | + | "getrandom 0.3.4", | |
| 6568 | 6568 | "once_cell", | |
| 6569 | 6569 | "rustix", | |
| 6570 | - | "windows-sys 0.61.2", | |
| 6570 | + | "windows-sys 0.59.0", | |
| 6571 | 6571 | ] | |
| 6572 | 6572 | ||
| 6573 | 6573 | [[package]] | |
| @@ -6589,7 +6589,7 @@ | |||
| 6589 | 6589 | "parking_lot", | |
| 6590 | 6590 | "rustix", | |
| 6591 | 6591 | "signal-hook", | |
| 6592 | - | "windows-sys 0.61.2", | |
| 6592 | + | "windows-sys 0.60.2", | |
| 6593 | 6593 | ] | |
| 6594 | 6594 | ||
| 6595 | 6595 | [[package]] | |
| @@ -7071,7 +7071,7 @@ | |||
| 7071 | 7071 | "png 0.18.1", | |
| 7072 | 7072 | "serde", | |
| 7073 | 7073 | "thiserror 2.0.20", | |
| 7074 | - | "windows-sys 0.61.2", | |
| 7074 | + | "windows-sys 0.60.2", | |
| 7075 | 7075 | ] | |
| 7076 | 7076 | ||
| 7077 | 7077 | [[package]] | |
| @@ -7106,7 +7106,7 @@ | |||
| 7106 | 7106 | dependencies = [ | |
| 7107 | 7107 | "memoffset", | |
| 7108 | 7108 | "tempfile", | |
| 7109 | - | "windows-sys 0.61.2", | |
| 7109 | + | "windows-sys 0.60.2", | |
| 7110 | 7110 | ] | |
| 7111 | 7111 | ||
| 7112 | 7112 | [[package]] | |
| @@ -7636,7 +7636,7 @@ | |||
| 7636 | 7636 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7637 | 7637 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 7638 | 7638 | dependencies = [ | |
| 7639 | - | "windows-sys 0.61.2", | |
| 7639 | + | "windows-sys 0.59.0", | |
| 7640 | 7640 | ] | |
| 7641 | 7641 | ||
| 7642 | 7642 | [[package]] | |
| @@ -8493,10 +8493,6 @@ | |||
| 8493 | 8493 | "winnow 1.0.4", | |
| 8494 | 8494 | ] | |
| 8495 | 8495 | ||
| 8496 | - | [[patch.unused]] | |
| 8497 | - | name = "ops-status" | |
| 8498 | - | version = "0.1.0" | |
| 8499 | - | ||
| 8500 | 8496 | [[patch.unused]] | |
| 8501 | 8497 | name = "quasi-axum" | |
| 8502 | 8498 | version = "0.56.0" | |
| @@ -8512,3 +8508,7 @@ | |||
| 8512 | 8508 | [[patch.unused]] | |
| 8513 | 8509 | name = "quasi-store" | |
| 8514 | 8510 | version = "0.1.0" | |
| 8511 | + | ||
| 8512 | + | [[patch.unused]] | |
| 8513 | + | name = "ops-status" | |
| 8514 | + | version = "0.1.0" |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | goingson-core = { workspace = true } | |
| 43 | 43 | painhours = { workspace = true } | |
| 44 | 44 | goingson-db-sqlite = { workspace = true } | |
| 45 | - | synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.8" } | |
| 45 | + | synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.9" } | |
| 46 | 46 | synckit-config = { git = "https://makenot.work/git/max/synckit.git", version = "0.2" } | |
| 47 | 47 | ||
| 48 | 48 | # The quasi stack, which is the frontend. Git URLs redirected to the |
| @@ -222,6 +222,22 @@ | |||
| 222 | 222 | warn!("SyncStore group migration failed (will retry next launch): {e}"); | |
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | + | // The group directory (synckit 0.9.0). Its tables come with the sync DDL | |
| 226 | + | // above, but the described project screen reads them whether or not sync | |
| 227 | + | // was ever configured, and an app that has never been signed in has run | |
| 228 | + | // none of that. Two no-op statements on a device that has, and the | |
| 229 | + | // difference between an empty directory and a missing table on one that | |
| 230 | + | // has not. Non-fatal: every read degrades to "this device knows no | |
| 231 | + | // groups", which is what the screen then says. | |
| 232 | + | match db.conn() { | |
| 233 | + | Ok(conn) => { | |
| 234 | + | if let Err(e) = synckit_client::store::directory::ensure_tables(&conn) { | |
| 235 | + | warn!("could not create the group directory tables: {e}"); | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | Err(e) => warn!("could not open a connection for the group directory: {e}"), | |
| 239 | + | } | |
| 240 | + | ||
| 225 | 241 | // Config (step 6): regenerate triggers so an already-migrated device gains | |
| 226 | 242 | // the user_config config triggers. No-op on a fresh install (the cutover | |
| 227 | 243 | // already generated them) and once applied. Non-fatal. |
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | use goingson_db_sqlite::Db; | |
| 14 | 14 | use rusqlite::params_from_iter; | |
| 15 | 15 | use serde::Serialize; | |
| 16 | + | use synckit_client::store::directory; | |
| 16 | 17 | use synckit_client::{GroupId, InvitationId, SyncKitClient, UserId, identity::IdentityPublicKey}; | |
| 17 | 18 | use tauri::State; | |
| 18 | 19 | use tracing::instrument; | |
| @@ -55,6 +56,30 @@ | |||
| 55 | 56 | .create_group(&name) | |
| 56 | 57 | .await | |
| 57 | 58 | .map_api_err("Failed to create group", ApiError::external_service)?; | |
| 59 | + | ||
| 60 | + | // Into the directory on the way past, so the group is usable before the next | |
| 61 | + | // sync cycle. Without this, making a group and immediately sharing a project | |
| 62 | + | // into it fails the membership check in `share_project_local`, which reads | |
| 63 | + | // the directory: the group is real on the server and unknown to this device. | |
| 64 | + | // | |
| 65 | + | // Additive rather than a replace, because the sync loop's write is the | |
| 66 | + | // authoritative one and this is filling in ahead of it. The creator is the | |
| 67 | + | // admin by construction. | |
| 68 | + | { | |
| 69 | + | let mut conn = state.db.conn()?; | |
| 70 | + | let known = directory::KnownGroup { | |
| 71 | + | id: group.id, | |
| 72 | + | name: group.name.clone(), | |
| 73 | + | gck_version: group.gck_version, | |
| 74 | + | is_admin: true, | |
| 75 | + | }; | |
| 76 | + | if let Err(error) = directory::add_group(&mut conn, &known) { | |
| 77 | + | // Not fatal: the group exists, and the next cycle writes the whole | |
| 78 | + | // directory anyway. Sharing into it before then is what degrades. | |
| 79 | + | tracing::warn!(%error, "could not record the new group in the directory"); | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 58 | 83 | Ok(GroupDto { | |
| 59 | 84 | id: group.id.to_string(), | |
| 60 | 85 | name: group.name, | |
| @@ -401,16 +426,22 @@ | |||
| 401 | 426 | // Project scope write path | |
| 402 | 427 | ||
| 403 | 428 | /// Share a whole project into a group: stamp `group_id` across its entire subtree. | |
| 404 | - | #[tauri::command] | |
| 405 | - | #[instrument(skip_all)] | |
| 406 | - | pub async fn share_project( | |
| 407 | - | state: State<'_, Arc<AppState>>, | |
| 408 | - | project_id: String, | |
| 409 | - | group_id: String, | |
| 410 | - | ) -> Result<bool, ApiError> { | |
| 429 | + | /// | |
| 430 | + | /// The synchronous core, so a described screen can call it. Everything this does | |
| 431 | + | /// is local, which is the change synckit 0.9.0 bought: the membership check was | |
| 432 | + | /// `client.list_groups().await` and is now a read of `sync_groups`, the directory | |
| 433 | + | /// the sync loop writes down each cycle. | |
| 434 | + | /// | |
| 435 | + | /// See [`crate::quasi::projects`] for the screen and for why the picker beside | |
| 436 | + | /// it needed the same directory. | |
| 437 | + | pub(crate) fn share_project_local( | |
| 438 | + | state: &AppState, | |
| 439 | + | project_id: &str, | |
| 440 | + | group_id: &str, | |
| 441 | + | ) -> Result<(), ApiError> { | |
| 411 | 442 | // Validate the id shape even though the column is plain TEXT. | |
| 412 | - | let group = parse_uuid("group id", &group_id)?; | |
| 413 | - | let pid = parse_uuid("project id", &project_id)?; | |
| 443 | + | let group = parse_uuid("group id", group_id)?; | |
| 444 | + | let pid = parse_uuid("project id", project_id)?; | |
| 414 | 445 | ||
| 415 | 446 | // Resolve the parent first, the way every other command does. Without this a | |
| 416 | 447 | // wrong or stale project id stamped zero rows and still reported success, so | |
| @@ -422,22 +453,38 @@ | |||
| 422 | 453 | ||
| 423 | 454 | // And confirm the caller is actually in the group. Stamping a scope the sync | |
| 424 | 455 | // engine holds no key for routes the whole subtree into a changelog scope that | |
| 425 | - | // goes nowhere, and unshare_project is the only way back — which the user has | |
| 456 | + | // goes nowhere, and unshare_project is the only way back, which the user has | |
| 426 | 457 | // no reason to run, having been told the share worked. | |
| 427 | - | let client = require_client(&state)?; | |
| 428 | - | let groups = client | |
| 429 | - | .list_groups() | |
| 430 | - | .await | |
| 431 | - | .map_api_err("Failed to list groups", ApiError::external_service)?; | |
| 432 | - | if !groups.iter().any(|g| g.id == GroupId::from(group)) { | |
| 458 | + | // | |
| 459 | + | // Answered from the directory rather than from the server. The two agree as | |
| 460 | + | // of the last sync, and `group_create` writes its own group in on the way | |
| 461 | + | // past, so the case that would otherwise be wrong (share into a group made | |
| 462 | + | // seconds ago, before any cycle has run) is covered where it arises. | |
| 463 | + | let conn = state.db.conn()?; | |
| 464 | + | if !directory::is_member(&conn, GroupId::from(group)) | |
| 465 | + | .map_err(|e| ApiError::internal(format!("Failed to read the group directory: {e}")))? | |
| 466 | + | { | |
| 433 | 467 | return Err(ApiError::validation( | |
| 434 | 468 | "groupId", | |
| 435 | 469 | "You are not a member of that group", | |
| 436 | 470 | )); | |
| 437 | 471 | } | |
| 472 | + | drop(conn); | |
| 438 | 473 | ||
| 439 | - | set_project_scope(&state.db, DESKTOP_USER_ID, &project_id, Some(&group_id)) | |
| 474 | + | set_project_scope(&state.db, DESKTOP_USER_ID, project_id, Some(group_id)) | |
| 440 | 475 | .map_api_err("Failed to share project", ApiError::internal)?; | |
| 476 | + | Ok(()) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// Share a whole project into a group: stamp `group_id` across its entire subtree. | |
| 480 | + | #[tauri::command] | |
| 481 | + | #[instrument(skip_all)] | |
| 482 | + | pub async fn share_project( | |
| 483 | + | state: State<'_, Arc<AppState>>, | |
| 484 | + | project_id: String, | |
| 485 | + | group_id: String, | |
| 486 | + | ) -> Result<bool, ApiError> { | |
| 487 | + | share_project_local(&state, &project_id, &group_id)?; | |
| 441 | 488 | Ok(true) | |
| 442 | 489 | } | |
| 443 | 490 |
| @@ -41,29 +41,35 @@ | |||
| 41 | 41 | //! under, or acting resets the view. [`filtered`] is that, applied to the | |
| 42 | 42 | //! detail address, the create form and both writes. | |
| 43 | 43 | //! | |
| 44 | - | //! # Sharing: half of it is here, and the half that is not is not this screen's | |
| 44 | + | //! # Sharing, and the thing that had to exist before it could be described | |
| 45 | 45 | //! | |
| 46 | - | //! A project's scope is a local column, so the mark and the way out are both | |
| 47 | - | //! sayable. A row carries a `Shared` badge when `group_id` is set, and the detail | |
| 48 | - | //! pane offers "Move back to personal", which is | |
| 49 | - | //! [`crate::commands::group::set_project_scope`] with `None`. | |
| 46 | + | //! A row carries a `Shared` badge when `group_id` is set. A personal project | |
| 47 | + | //! offers a picker over the user's groups; a shared one offers the way back. | |
| 50 | 48 | //! | |
| 51 | - | //! **Sharing INTO a group is not offered, and the reason is not this screen.** | |
| 52 | - | //! The control would be a picker over the groups the user belongs to, and | |
| 53 | - | //! `commands::group::group_list` answers that by awaiting the SyncKit client. A | |
| 54 | - | //! `quasi_router::Handler` is `fn(&S, Request) -> Result<Response, RouteError>`, | |
| 55 | - | //! so a handler cannot await, and there is no local table of groups to read | |
| 56 | - | //! instead: membership lives on the server. | |
| 49 | + | //! **Neither the picker nor the write was describable until synckit 0.9.0**, and | |
| 50 | + | //! the reason is worth keeping because it is the general shape. A handler is | |
| 51 | + | //! `fn(&S, Request) -> Result<Response, RouteError>`, so it cannot await, and | |
| 52 | + | //! both halves of sharing wanted the network: the picker needed `group_list` to | |
| 53 | + | //! name the options, and `share_project` awaited `list_groups` to confirm the | |
| 54 | + | //! user is actually in the group before stamping a scope. That check is worth | |
| 55 | + | //! making, since a scope the engine holds no key for routes the whole subtree | |
| 56 | + | //! into a changelog that goes nowhere. | |
| 57 | 57 | //! | |
| 58 | - | //! This is the same gap that keeps Settings > Sharing undescribed, recorded in | |
| 59 | - | //! [`super`]'s table as "its reads are remote, so there is no local state to draw | |
| 60 | - | //! a section from" and filed as quasicoherent `82273265`. Two screens now want | |
| 61 | - | //! the same thing, which is worth knowing when that task is picked up. | |
| 58 | + | //! What resolved it was not a new capability. The sync loop had always asked the | |
| 59 | + | //! server which groups to sync, once per cycle, and always thrown the names away. | |
| 60 | + | //! synckit writes that answer down now (`sync_groups`, and | |
| 61 | + | //! `synckit_client::store::directory`), so both halves read a local table: | |
| 62 | + | //! [`known_groups`] for the options, `directory::is_member` for the check. The | |
| 63 | + | //! remote read still happens, on a schedule, outside the request loop, which is | |
| 64 | + | //! where a description can live with it. | |
| 62 | 65 | //! | |
| 63 | - | //! Offering only the way out is deliberate rather than an oversight. A project | |
| 64 | - | //! shared from another device shows as shared and can be moved back, which is | |
| 65 | - | //! the reversal path; what is missing is the way in, and a control that could | |
| 66 | - | //! not populate its own options would be worse than none. | |
| 66 | + | //! quasicoherent `82273265` is where that argument was settled; this screen and | |
| 67 | + | //! Settings > Sharing were its two consumers. | |
| 68 | + | //! | |
| 69 | + | //! The picker is withheld when the directory is empty rather than drawn with no | |
| 70 | + | //! options. An empty directory means this device has not synced since groups | |
| 71 | + | //! existed, which is a different fact from having no groups, and a control that | |
| 72 | + | //! cannot populate itself is worse than none. | |
| 67 | 73 | ||
| 68 | 74 | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 69 | 75 | // plain `fn(&S, Request)` pointer, so the signature is the router's and not a | |
| @@ -409,10 +415,38 @@ | |||
| 409 | 415 | slot = slot.with(Node::text(&project.description)); | |
| 410 | 416 | } | |
| 411 | 417 | ||
| 412 | - | // Unshare, and deliberately not share. See the module header: moving a project | |
| 413 | - | // back to personal scope is a local write and this is the whole of it, while | |
| 414 | - | // sharing needs the group list, which is a remote read a described handler | |
| 415 | - | // cannot make. | |
| 418 | + | // Share, offered only on a personal project and only when this device knows | |
| 419 | + | // of a group to offer. Both halves read the directory synckit writes each | |
| 420 | + | // cycle; see the module header for why that had to exist first. | |
| 421 | + | if project.group_id.is_none() { | |
| 422 | + | let groups = known_groups(state)?; | |
| 423 | + | if !groups.is_empty() { | |
| 424 | + | slot = slot.with(Node::Form { | |
| 425 | + | action: filtered( | |
| 426 | + | Action::post(format!("/projects/{}/share", project.id)), | |
| 427 | + | shared_only, | |
| 428 | + | show_retired, | |
| 429 | + | ), | |
| 430 | + | submit: "Share into a group".to_owned(), | |
| 431 | + | fields: vec![ | |
| 432 | + | Field::select( | |
| 433 | + | "group_id", | |
| 434 | + | "Group", | |
| 435 | + | groups | |
| 436 | + | .iter() | |
| 437 | + | .map(|group| Choice::new(group.id.to_string(), &group.name)) | |
| 438 | + | .collect(), | |
| 439 | + | ) | |
| 440 | + | .required() | |
| 441 | + | .hint( | |
| 442 | + | "Everything in the project goes with it: its tasks, events, \ | |
| 443 | + | milestones and attachments.", | |
| 444 | + | ), | |
| 445 | + | ], | |
| 446 | + | }); | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 416 | 450 | if project.group_id.is_some() { | |
| 417 | 451 | slot = slot.with(Node::text( | |
| 418 | 452 | "Shared into a group. Its tasks, events, milestones and attachments \ | |
| @@ -679,6 +713,64 @@ | |||
| 679 | 713 | wrote(state, flag(&request, "shared"), flag(&request, "retired")) | |
| 680 | 714 | } | |
| 681 | 715 | ||
| 716 | + | /// The groups this device knows the user belongs to, by name. | |
| 717 | + | /// | |
| 718 | + | /// `synckit_client::store::directory`, read through goingson's own pool. The | |
| 719 | + | /// directory is written by the sync loop each cycle out of an answer it was | |
| 720 | + | /// already fetching, which is what makes a group nameable from a synchronous | |
| 721 | + | /// handler at all. An empty answer means this device has not synced since groups | |
| 722 | + | /// existed, not that the user has none, so a screen offers no picker rather than | |
| 723 | + | /// claiming there is nothing to share into. | |
| 724 | + | fn known_groups( | |
| 725 | + | state: &AppState, | |
| 726 | + | ) -> Result<Vec<synckit_client::store::sync::KnownGroup>, RouteError> { | |
| 727 | + | let conn = state | |
| 728 | + | .db | |
| 729 | + | .conn() | |
| 730 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 731 | + | synckit_client::store::directory::groups(&conn) | |
| 732 | + | .map_err(|error| RouteError::internal(error.to_string())) | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | /// Share a project and its whole subtree into a group. | |
| 736 | + | /// | |
| 737 | + | /// The write is [`crate::commands::group::share_project_local`], which is | |
| 738 | + | /// synchronous because synckit 0.9.0 made its membership check a local read of | |
| 739 | + | /// the directory. Before that it was `client.list_groups().await` and this route | |
| 740 | + | /// could not have existed. | |
| 741 | + | fn share(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 742 | + | let id = project_id(&request)?; | |
| 743 | + | let group = request | |
| 744 | + | .payload | |
| 745 | + | .get("group_id") | |
| 746 | + | .unwrap_or_default() | |
| 747 | + | .trim() | |
| 748 | + | .to_owned(); | |
| 749 | + | ||
| 750 | + | // A select offers a fixed set, so a value that is not one of them did not | |
| 751 | + | // come from the form. The refusal below covers both that and a group this | |
| 752 | + | // device does not know it belongs to, which is the same answer to the user. | |
| 753 | + | if let Err(error) = crate::commands::group::share_project_local(state, &id.to_string(), &group) | |
| 754 | + | { | |
| 755 | + | return Ok(Response::from(screen( | |
| 756 | + | state, | |
| 757 | + | flag(&request, "shared"), | |
| 758 | + | flag(&request, "retired"), | |
| 759 | + | )?) | |
| 760 | + | .toast(makeover_layout::Tone::Danger, error.to_string())); | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | Ok(Response::from(screen( | |
| 764 | + | state, | |
| 765 | + | flag(&request, "shared"), | |
| 766 | + | flag(&request, "retired"), | |
| 767 | + | )?) | |
| 768 | + | .toast( | |
| 769 | + | makeover_layout::Tone::Success, | |
| 770 | + | "Shared. Everything in the project went with it.", | |
| 771 | + | )) | |
| 772 | + | } | |
| 773 | + | ||
| 682 | 774 | /// Move a project and its whole subtree back to personal scope. | |
| 683 | 775 | /// | |
| 684 | 776 | /// The write is [`crate::commands::group::set_project_scope`] with `None`, which | |
| @@ -717,6 +809,7 @@ | |||
| 717 | 809 | .get("/projects/{id}", detail) | |
| 718 | 810 | .post("/projects", create) | |
| 719 | 811 | .post("/projects/{id}/delete", remove) | |
| 812 | + | .post("/projects/{id}/share", share) | |
| 720 | 813 | .post("/projects/{id}/unshare", unshare); | |
| 721 | 814 | dashboard::routes(router) | |
| 722 | 815 | } |
| @@ -737,6 +737,125 @@ | |||
| 737 | 737 | .unwrap() | |
| 738 | 738 | } | |
| 739 | 739 | ||
| 740 | + | /// Put a group in the directory the way the sync loop does, so a described | |
| 741 | + | /// handler can read it. That the loop writes this is synckit's test; what is | |
| 742 | + | /// under test here is a screen reading it. | |
| 743 | + | fn known_group(state: &AppState, id: u128, name: &str) { | |
| 744 | + | let mut conn = state.db.conn().unwrap(); | |
| 745 | + | // The sync DDL runs when a `SyncStore` is built, which a test state does not | |
| 746 | + | // do. The directory's own DDL is separable for exactly this reason, so what | |
| 747 | + | // is under test is a screen reading the table rather than the table's | |
| 748 | + | // absence, which `a_device_that_knows_no_groups_is_offered_no_picker` covers. | |
| 749 | + | synckit_client::store::directory::ensure_tables(&conn).unwrap(); | |
| 750 | + | synckit_client::store::directory::add_group( | |
| 751 | + | &mut conn, | |
| 752 | + | &synckit_client::store::directory::KnownGroup { | |
| 753 | + | id: synckit_client::GroupId::new(uuid::Uuid::from_u128(id)), | |
| 754 | + | name: name.to_owned(), | |
| 755 | + | gck_version: 1, | |
| 756 | + | is_admin: true, | |
| 757 | + | }, | |
| 758 | + | ) | |
| 759 | + | .unwrap(); | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | fn pane(state: &AppState, project: &goingson_core::Project) -> String { | |
| 763 | + | let Outcome::Fragment { node, .. } = | |
| 764 | + | answer(state, &format!("/projects/{}", project.id), Params::new()).outcome | |
| 765 | + | else { | |
| 766 | + | panic!("the detail pane answers with a fragment"); | |
| 767 | + | }; | |
| 768 | + | quasi_webview::Webview::new().fragment(&node) | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | /// An empty directory means this device has not synced since groups existed, | |
| 772 | + | /// not that the user has no groups, so the control is withheld rather than | |
| 773 | + | /// drawn with nothing in it. | |
| 774 | + | #[tokio::test] | |
| 775 | + | async fn a_device_that_knows_no_groups_is_offered_no_picker() { | |
| 776 | + | let state = state().await; | |
| 777 | + | let personal = add(&state, "Mine alone", ProjectStatus::Active); | |
| 778 | + | let shown = pane(&state, &personal); | |
| 779 | + | assert!(!shown.contains("Share into a group"), "{shown}"); | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | #[tokio::test] | |
| 783 | + | async fn a_personal_project_offers_the_groups_this_device_knows() { | |
| 784 | + | let state = state().await; | |
| 785 | + | let personal = add(&state, "Mine alone", ProjectStatus::Active); | |
| 786 | + | known_group(&state, 1, "The Firm"); | |
| 787 | + | ||
| 788 | + | let shown = pane(&state, &personal); | |
| 789 | + | assert!(shown.contains("Share into a group"), "{shown}"); | |
| 790 | + | assert!(shown.contains("The Firm"), "{shown}"); | |
| 791 | + | assert!( | |
| 792 | + | shown.contains(&format!("/projects/{}/share", personal.id)), | |
| 793 | + | "{shown}" | |
| 794 | + | ); | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | /// One project is in one scope, so a shared project is offered the way out and | |
| 798 | + | /// not a second way in. | |
| 799 | + | #[tokio::test] | |
| 800 | + | async fn a_shared_project_is_not_offered_the_picker_again() { | |
| 801 | + | let state = state().await; | |
| 802 | + | let shared = add(&state, "Shared work", ProjectStatus::Active); | |
| 803 | + | known_group(&state, 1, "The Firm"); | |
| 804 | + | share_into(&state, &shared, "00000000-0000-0000-0000-000000000001"); | |
| 805 | + | ||
| 806 | + | let shown = pane(&state, &shared); | |
| 807 | + | assert!(!shown.contains("Share into a group"), "{shown}"); | |
| 808 | + | assert!(shown.contains("Move back to personal"), "{shown}"); | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | #[tokio::test] | |
| 812 | + | async fn sharing_stamps_the_scope_across_the_subtree() { | |
| 813 | + | let state = state().await; | |
| 814 | + | let project = add(&state, "Mine alone", ProjectStatus::Active); | |
| 815 | + | known_group(&state, 1, "The Firm"); | |
| 816 | + | let group = "00000000-0000-0000-0000-000000000001"; | |
| 817 | + | ||
| 818 | + | let task = state | |
| 819 | + | .tasks | |
| 820 | + | .create( | |
| 821 | + | DESKTOP_USER_ID, | |
| 822 | + | goingson_core::NewTask::builder("Inside it") | |
| 823 | + | .project_id(project.id) | |
| 824 | + | .build(), | |
| 825 | + | ) | |
| 826 | + | .unwrap(); | |
| 827 | + | ||
| 828 | + | let mut params = Params::new(); | |
| 829 | + | params.insert("group_id".to_owned(), group.to_owned()); | |
| 830 | + | post(&state, &format!("/projects/{}/share", project.id), params); | |
| 831 | + | ||
| 832 | + | assert_eq!(scope_of(&state, &project).as_deref(), Some(group)); | |
| 833 | + | assert_eq!( | |
| 834 | + | scope_of_task(&state, &task).as_deref(), | |
| 835 | + | Some(group), | |
| 836 | + | "and everything in it went too" | |
| 837 | + | ); | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | /// The check `share_project` used to make against the server, made against the | |
| 841 | + | /// directory. Stamping a scope the engine holds no key for routes the whole | |
| 842 | + | /// subtree into a changelog that goes nowhere. | |
| 843 | + | #[tokio::test] | |
| 844 | + | async fn sharing_into_a_group_this_device_does_not_know_is_refused() { | |
| 845 | + | let state = state().await; | |
| 846 | + | let project = add(&state, "Mine alone", ProjectStatus::Active); | |
| 847 | + | known_group(&state, 1, "The Firm"); | |
| 848 | + | ||
| 849 | + | let mut params = Params::new(); | |
| 850 | + | params.insert( | |
| 851 | + | "group_id".to_owned(), | |
| 852 | + | "00000000-0000-0000-0000-0000000000ff".to_owned(), | |
| 853 | + | ); | |
| 854 | + | post(&state, &format!("/projects/{}/share", project.id), params); | |
| 855 | + | ||
| 856 | + | assert!(scope_of(&state, &project).is_none(), "nothing was stamped"); | |
| 857 | + | } | |
| 858 | + | ||
| 740 | 859 | /// A stale id stamps zero rows and would otherwise report success, which is | |
| 741 | 860 | /// `share_project`'s own recorded lesson asked of the way back out. | |
| 742 | 861 | #[tokio::test] |