Resolve conflicts together, live 5e6d6484c28e
Everyone on the same resolver page shares a room (per repository,
bookmark and path, in memory, over server-sent events at
/{owner}/{name}/resolve-live/{bookmark}/{path}): they see who else is
there and each other's choices as they make them, region by region or
the candidate to keep, the last choice for a region winning; free text is
shared whole a moment after typing stops. A newcomer gets the choices so
far. When one of them commits, the others are told the resolution landed,
with a link, and their form is disabled. Writers only, choices checked,
CSRF on the shared choices; a room ends when its last viewer leaves.
closes #33
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 05:44 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
5e6d6484c28e5470b03062fafefc56013332df25e55bcbbc130ebc0368a3ff53- Depends on
- 145306044896 34beb1a7e376 93be9ce4653e 9e5ef6c23006 b64e2537d702 c5f3bde466d1 d23464c0ac60 fd958fbc06fb
- Depended on by
- c7067c7bda56 33d98147511b c92a99490cba a46d15e72101 2a83f5303458 3556d08e17f9 d3e75fb41e61 2bfe5ef66b0c
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 21 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/hub/src/live.rs +181 −0
| 1 | + | //! Live collaboration on the conflict resolver (hub spec §6.10): everyone |
|
| 2 | + | //! looking at the same conflict sees who else is there, the choices each |
|
| 3 | + | //! makes region by region (the last choice for a region wins), and the |
|
| 4 | + | //! resolution when one of them commits it. Rooms live in memory: they are |
|
| 5 | + | //! for people working together now, not a record. |
|
| 6 | + | ||
| 7 | + | use std::collections::{BTreeMap, HashMap}; |
|
| 8 | + | use std::sync::{Arc, Mutex}; |
|
| 9 | + | use tokio::sync::broadcast; |
|
| 10 | + | ||
| 11 | + | /// One conflict being resolved: repository, bookmark, path. |
|
| 12 | + | #[derive(Clone, Debug, PartialEq, Eq, Hash)] |
|
| 13 | + | pub struct RoomKey { |
|
| 14 | + | pub repo_id: i64, |
|
| 15 | + | pub bookmark: String, |
|
| 16 | + | pub path: String, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | /// A region's current choice: `side1`…, `all`, `none` or `text`, with the |
|
| 20 | + | /// text for `text`, and who made it. |
|
| 21 | + | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] |
|
| 22 | + | pub struct Choice { |
|
| 23 | + | pub choice: String, |
|
| 24 | + | pub text: String, |
|
| 25 | + | pub by: String, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /// What a room tells its viewers. |
|
| 29 | + | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] |
|
| 30 | + | #[serde(tag = "kind", rename_all = "lowercase")] |
|
| 31 | + | pub enum LiveEvent { |
|
| 32 | + | /// Everything so far, sent to a viewer who joins. |
|
| 33 | + | Snapshot { present: Vec<String>, choices: BTreeMap<String, Choice>, keep: Option<Choice> }, |
|
| 34 | + | /// Who is looking now. |
|
| 35 | + | Presence { present: Vec<String> }, |
|
| 36 | + | /// A viewer chose for region `region` (`keep` for a candidate page). |
|
| 37 | + | Choice { region: String, choice: Choice, client: String }, |
|
| 38 | + | /// A resolution was committed; `url` is its change. |
|
| 39 | + | Landed { url: String, by: String }, |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | struct Room { |
|
| 43 | + | tx: broadcast::Sender<LiveEvent>, |
|
| 44 | + | /// Connected viewers by client id: their user names. |
|
| 45 | + | present: HashMap<String, String>, |
|
| 46 | + | choices: BTreeMap<String, Choice>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | impl Room { |
|
| 50 | + | fn names(&self) -> Vec<String> { |
|
| 51 | + | let mut v: Vec<String> = self.present.values().cloned().collect(); |
|
| 52 | + | v.sort(); |
|
| 53 | + | v.dedup(); |
|
| 54 | + | v |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | #[derive(Default)] |
|
| 59 | + | pub struct Rooms { |
|
| 60 | + | rooms: Mutex<HashMap<RoomKey, Room>>, |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// A viewer's place in a room; leaving happens when it is dropped (the |
|
| 64 | + | /// viewer's event stream closed). |
|
| 65 | + | pub struct Seat { |
|
| 66 | + | rooms: Arc<Rooms>, |
|
| 67 | + | key: RoomKey, |
|
| 68 | + | client: String, |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | impl Drop for Seat { |
|
| 72 | + | fn drop(&mut self) { |
|
| 73 | + | self.rooms.leave(&self.key, &self.client); |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | impl Rooms { |
|
| 78 | + | /// Join `key` as `client` (one browser tab) of `user`: the events to |
|
| 79 | + | /// follow, starting with a snapshot, and the seat that leaves on drop. |
|
| 80 | + | pub fn join(&self, key: RoomKey, client: &str, user: &str) -> (broadcast::Receiver<LiveEvent>, LiveEvent) { |
|
| 81 | + | let mut rooms = self.rooms.lock().unwrap(); |
|
| 82 | + | let room = |
|
| 83 | + | rooms.entry(key).or_insert_with(|| Room { tx: broadcast::channel(256).0, present: HashMap::new(), choices: BTreeMap::new() }); |
|
| 84 | + | room.present.insert(client.to_string(), user.to_string()); |
|
| 85 | + | let rx = room.tx.subscribe(); |
|
| 86 | + | let present = room.names(); |
|
| 87 | + | let _ = room.tx.send(LiveEvent::Presence { present: present.clone() }); |
|
| 88 | + | let mut choices = room.choices.clone(); |
|
| 89 | + | let keep = choices.remove("keep"); |
|
| 90 | + | (rx, LiveEvent::Snapshot { present, choices, keep }) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// A seat for `client` in `key`, to hold while its stream is open. |
|
| 94 | + | pub fn seat(self: &Arc<Self>, key: RoomKey, client: &str) -> Seat { |
|
| 95 | + | Seat { rooms: Arc::clone(self), key, client: client.to_string() } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | fn leave(&self, key: &RoomKey, client: &str) { |
|
| 99 | + | let mut rooms = self.rooms.lock().unwrap(); |
|
| 100 | + | let Some(room) = rooms.get_mut(key) else { return }; |
|
| 101 | + | room.present.remove(client); |
|
| 102 | + | if room.present.is_empty() { |
|
| 103 | + | // Nobody is looking: forget the room and its choices. |
|
| 104 | + | rooms.remove(key); |
|
| 105 | + | } else { |
|
| 106 | + | let _ = room.tx.send(LiveEvent::Presence { present: room.names() }); |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Record and share a choice; `false` when nobody is in the room. |
|
| 111 | + | pub fn choose(&self, key: &RoomKey, client: &str, region: &str, choice: Choice) -> bool { |
|
| 112 | + | let mut rooms = self.rooms.lock().unwrap(); |
|
| 113 | + | let Some(room) = rooms.get_mut(key) else { return false }; |
|
| 114 | + | room.choices.insert(region.to_string(), choice.clone()); |
|
| 115 | + | let _ = room.tx.send(LiveEvent::Choice { region: region.to_string(), choice, client: client.to_string() }); |
|
| 116 | + | true |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | /// Tell a room its conflict was resolved. |
|
| 120 | + | pub fn landed(&self, key: &RoomKey, url: &str, by: &str) { |
|
| 121 | + | let rooms = self.rooms.lock().unwrap(); |
|
| 122 | + | if let Some(room) = rooms.get(key) { |
|
| 123 | + | let _ = room.tx.send(LiveEvent::Landed { url: url.to_string(), by: by.to_string() }); |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | /// How many rooms are open (for tests and the admin page). |
|
| 128 | + | pub fn open(&self) -> usize { |
|
| 129 | + | self.rooms.lock().unwrap().len() |
|
| 130 | + | } |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | #[cfg(test)] |
|
| 134 | + | mod tests { |
|
| 135 | + | use super::*; |
|
| 136 | + | ||
| 137 | + | fn key() -> RoomKey { |
|
| 138 | + | RoomKey { repo_id: 1, bookmark: "main".into(), path: "a.txt".into() } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | fn pick(choice: &str, by: &str) -> Choice { |
|
| 142 | + | Choice { choice: choice.into(), text: String::new(), by: by.into() } |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | #[test] |
|
| 146 | + | fn viewers_see_each_other_and_each_others_choices() { |
|
| 147 | + | let rooms = Arc::new(Rooms::default()); |
|
| 148 | + | let (mut a_rx, a_snap) = rooms.join(key(), "tab-a", "andy"); |
|
| 149 | + | assert_eq!(a_snap, LiveEvent::Snapshot { present: vec!["andy".into()], choices: BTreeMap::new(), keep: None }); |
|
| 150 | + | let seat_a = rooms.seat(key(), "tab-a"); |
|
| 151 | + | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Presence { .. })), "a's own arrival"); |
|
| 152 | + | ||
| 153 | + | assert!(rooms.choose(&key(), "tab-a", "1", pick("side2", "andy"))); |
|
| 154 | + | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Choice { region, .. }) if region == "1")); |
|
| 155 | + | ||
| 156 | + | // A second viewer gets the choices so far and is announced. |
|
| 157 | + | let (mut b_rx, b_snap) = rooms.join(key(), "tab-b", "bea"); |
|
| 158 | + | let seat_b = rooms.seat(key(), "tab-b"); |
|
| 159 | + | let LiveEvent::Snapshot { present, choices, .. } = b_snap else { panic!() }; |
|
| 160 | + | assert_eq!(present, vec!["andy", "bea"]); |
|
| 161 | + | assert_eq!(choices["1"].choice, "side2"); |
|
| 162 | + | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into(), "bea".into()] }); |
|
| 163 | + | ||
| 164 | + | // The last choice for a region wins; keep is apart for candidate pages. |
|
| 165 | + | rooms.choose(&key(), "tab-b", "1", pick("all", "bea")); |
|
| 166 | + | rooms.choose(&key(), "tab-b", "keep", pick("1", "bea")); |
|
| 167 | + | let _ = b_rx.try_recv(); |
|
| 168 | + | drop(seat_b); |
|
| 169 | + | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Choice { region: "1".into(), choice: pick("all", "bea"), client: "tab-b".into() }); |
|
| 170 | + | let _ = a_rx.try_recv(); |
|
| 171 | + | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into()] }, "bea left"); |
|
| 172 | + | ||
| 173 | + | rooms.landed(&key(), "/andy/demo/change/abc", "andy"); |
|
| 174 | + | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Landed { .. }))); |
|
| 175 | + | ||
| 176 | + | // The last viewer leaving closes the room. |
|
| 177 | + | drop(seat_a); |
|
| 178 | + | assert_eq!(rooms.open(), 0); |
|
| 179 | + | assert!(!rooms.choose(&key(), "tab-a", "1", pick("none", "andy"))); |
|
| 180 | + | } |
|
| 181 | + | } |
modified assets/hub.js +73 −0
| … | 269 unchanged lines |
||
| 270 | 270 | } |
|
| 271 | 271 | pending = e.key === "g" ? "g" : null; |
|
| 272 | 272 | }); |
|
| 273 | + | ||
| 274 | + | // Live conflict resolution (hub spec §6.10): who else is resolving this |
|
| 275 | + | // conflict, their choices region by region (the last one wins), and the |
|
| 276 | + | // resolution when someone commits it. |
|
| 277 | + | var live = document.querySelector("[data-resolve-live]"); |
|
| 278 | + | if (live && window.EventSource && window.fetch) { |
|
| 279 | + | var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf"); |
|
| 280 | + | var client = Math.random().toString(36).slice(2) + Date.now().toString(36); |
|
| 281 | + | var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]"); |
|
| 282 | + | var applying = false; |
|
| 283 | + | var fieldsOf = function (region) { |
|
| 284 | + | return region === "keep" |
|
| 285 | + | ? { radios: live.querySelectorAll('input[name="keep"]'), text: null } |
|
| 286 | + | : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') }; |
|
| 287 | + | }; |
|
| 288 | + | var apply = function (region, c) { |
|
| 289 | + | var f = fieldsOf(region); |
|
| 290 | + | applying = true; |
|
| 291 | + | f.radios.forEach(function (r) { r.checked = r.value === c.choice; }); |
|
| 292 | + | if (f.text && c.choice === "text") f.text.value = c.text; |
|
| 293 | + | applying = false; |
|
| 294 | + | var sec = f.radios.length ? f.radios[0].closest("section") : null; |
|
| 295 | + | if (sec) sec.setAttribute("title", "chosen by " + c.by); |
|
| 296 | + | }; |
|
| 297 | + | var send = function (region, choice, text) { |
|
| 298 | + | fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", |
|
| 299 | + | body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) }); |
|
| 300 | + | }; |
|
| 301 | + | var timers = {}; |
|
| 302 | + | live.addEventListener("change", function (ev) { |
|
| 303 | + | var t = ev.target; |
|
| 304 | + | if (applying || !t.name || t.type !== "radio") return; |
|
| 305 | + | var region = t.name === "keep" ? "keep" : t.name.slice(1); |
|
| 306 | + | var ta = live.querySelector('textarea[name="t' + region + '"]'); |
|
| 307 | + | send(region, t.value, t.value === "text" && ta ? ta.value : ""); |
|
| 308 | + | }); |
|
| 309 | + | live.addEventListener("input", function (ev) { |
|
| 310 | + | var t = ev.target; |
|
| 311 | + | if (applying || t.tagName !== "TEXTAREA") return; |
|
| 312 | + | var region = t.name.slice(1); |
|
| 313 | + | live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; }); |
|
| 314 | + | clearTimeout(timers[region]); |
|
| 315 | + | timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400); |
|
| 316 | + | }); |
|
| 317 | + | var es = new EventSource(url + "?client=" + encodeURIComponent(client)); |
|
| 318 | + | var showPresent = function (names) { |
|
| 319 | + | present.hidden = names.length < 2; |
|
| 320 | + | present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins."; |
|
| 321 | + | }; |
|
| 322 | + | es.addEventListener("snapshot", function (e) { |
|
| 323 | + | var d = JSON.parse(e.data); |
|
| 324 | + | showPresent(d.present); |
|
| 325 | + | Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); }); |
|
| 326 | + | if (d.keep) apply("keep", d.keep); |
|
| 327 | + | }); |
|
| 328 | + | es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); }); |
|
| 329 | + | es.addEventListener("choice", function (e) { |
|
| 330 | + | var d = JSON.parse(e.data); |
|
| 331 | + | if (d.client !== client) apply(d.region, d.choice); |
|
| 332 | + | }); |
|
| 333 | + | es.addEventListener("landed", function (e) { |
|
| 334 | + | var d = JSON.parse(e.data); |
|
| 335 | + | es.close(); |
|
| 336 | + | landed.hidden = false; |
|
| 337 | + | landed.textContent = ""; |
|
| 338 | + | landed.appendChild(document.createTextNode(d.by + " committed the resolution: ")); |
|
| 339 | + | var a = document.createElement("a"); |
|
| 340 | + | a.href = d.url; |
|
| 341 | + | a.textContent = "see the change"; |
|
| 342 | + | landed.appendChild(a); |
|
| 343 | + | live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; }); |
|
| 344 | + | }); |
|
| 345 | + | } |
|
| 273 | 346 | })(); |
modified crates/hub/src/lib.rs +4 −0
| … | 10 unchanged lines |
||
| 11 | 11 | pub mod flow; |
|
| 12 | 12 | pub mod hooks; |
|
| 13 | 13 | pub mod keys; |
|
| 14 | + | pub mod live; |
|
| 14 | 15 | pub mod mail; |
|
| 15 | 16 | pub mod mirror; |
|
| 16 | 17 | pub mod notify; |
|
| … | 15 unchanged lines |
||
| 32 | 33 | pub hub_key: keys::HubKey, |
|
| 33 | 34 | pub cookie_key: auth::CookieKey, |
|
| 34 | 35 | pub events: events::Events, |
|
| 36 | + | /// Who is resolving which conflict together (hub spec §6.10). |
|
| 37 | + | pub rooms: Arc<live::Rooms>, |
|
| 35 | 38 | pub started: std::time::Instant, |
|
| 36 | 39 | } |
|
| 37 | 40 | ||
| … | 18 unchanged lines |
||
| 56 | 59 | hub_key, |
|
| 57 | 60 | cookie_key, |
|
| 58 | 61 | events: events::Events::default(), |
|
| 62 | + | rooms: Arc::new(live::Rooms::default()), |
|
| 59 | 63 | started: std::time::Instant::now(), |
|
| 60 | 64 | }); |
|
| 61 | 65 | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| … | 38 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 139 unchanged lines |
||
| 140 | 140 | .route("/{owner}/{name}/edit/{r}", post(edit::save)) |
|
| 141 | 141 | .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form)) |
|
| 142 | 142 | .route("/{owner}/{name}/resolve/{bookmark}/{*path}", get(resolve::form).post(resolve::save)) |
|
| 143 | + | .route("/{owner}/{name}/resolve-live/{bookmark}/{*path}", get(resolve::live_events).post(resolve::live_choose)) |
|
| 143 | 144 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 144 | 145 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 145 | 146 | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| … | 435 unchanged lines |
modified crates/hub/src/web/resolve.rs +100 −3
| … | 478 unchanged lines |
||
| 479 | 479 | Err(e) => return again(e.to_string()).await, |
|
| 480 | 480 | }; |
|
| 481 | 481 | after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; |
|
| 482 | - | return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response()); |
|
| 482 | + | let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); |
|
| 483 | + | app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); |
|
| 484 | + | return Ok(Redirect::to(&url).into_response()); |
|
| 483 | 485 | } |
|
| 484 | 486 | Target::Name(candidates) => { |
|
| 485 | 487 | let again = |msg: String| { |
|
| … | 28 unchanged lines |
||
| 514 | 516 | Err(e) => return again(e.to_string()).await, |
|
| 515 | 517 | }; |
|
| 516 | 518 | after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; |
|
| 517 | - | return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response()); |
|
| 519 | + | let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); |
|
| 520 | + | app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); |
|
| 521 | + | return Ok(Redirect::to(&url).into_response()); |
|
| 518 | 522 | } |
|
| 519 | 523 | }; |
|
| 520 | 524 | if stale { |
|
| … | 33 unchanged lines |
||
| 554 | 558 | Err(e) => return again(e.to_string()).await, |
|
| 555 | 559 | }; |
|
| 556 | 560 | after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; |
|
| 557 | - | Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response()) |
|
| 561 | + | let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); |
|
| 562 | + | app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); |
|
| 563 | + | Ok(Redirect::to(&url).into_response()) |
|
| 558 | 564 | } |
|
| 559 | 565 | ||
| 560 | 566 | #[cfg(test)] |
|
| … | 28 unchanged lines |
||
| 589 | 595 | assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line"); |
|
| 590 | 596 | assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3")); |
|
| 591 | 597 | assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2")); |
|
| 598 | + | } |
|
| 599 | + | } |
|
| 600 | + | ||
| 601 | + | // -------------------------------------------------------------- live (P1b) |
|
| 602 | + | ||
| 603 | + | fn room(row: &crate::db::repos::RepoRow, bookmark: &str, path: &str) -> crate::live::RoomKey { |
|
| 604 | + | crate::live::RoomKey { repo_id: row.id, bookmark: bookmark.to_string(), path: path.to_string() } |
|
| 605 | + | } |
|
| 606 | + | ||
| 607 | + | #[derive(serde::Deserialize)] |
|
| 608 | + | pub struct LiveQuery { |
|
| 609 | + | /// This browser tab's id, so a tab ignores its own choices. |
|
| 610 | + | client: String, |
|
| 611 | + | } |
|
| 612 | + | ||
| 613 | + | /// `GET /{owner}/{name}/resolve-live/{bookmark}/{path}?client=…`: server-sent |
|
| 614 | + | /// events for everyone resolving this conflict (hub spec §6.10): a snapshot |
|
| 615 | + | /// of who is here and what they chose, then presence, choices, and the |
|
| 616 | + | /// resolution when it lands. Writers only, as the resolver. |
|
| 617 | + | pub async fn live_events( |
|
| 618 | + | State(app): State<Shared>, |
|
| 619 | + | viewer: Viewer, |
|
| 620 | + | Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, |
|
| 621 | + | axum::extract::Query(q): axum::extract::Query<LiveQuery>, |
|
| 622 | + | ) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> { |
|
| 623 | + | use futures::StreamExt; |
|
| 624 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); |
|
| 625 | + | let me = require(&viewer, &shell)?; |
|
| 626 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 627 | + | let client: String = q.client.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(64).collect(); |
|
| 628 | + | if client.is_empty() { |
|
| 629 | + | return Err(Error::bad(shell, "A live view needs a client id.")); |
|
| 592 | 630 | } |
|
| 631 | + | let key = room(&row, &bookmark, &path); |
|
| 632 | + | let (rx, snapshot) = app.rooms.join(key.clone(), &client, &me.account.username); |
|
| 633 | + | let seat = app.rooms.seat(key, &client); |
|
| 634 | + | let event = |e: &crate::live::LiveEvent| { |
|
| 635 | + | let kind = match e { |
|
| 636 | + | crate::live::LiveEvent::Snapshot { .. } => "snapshot", |
|
| 637 | + | crate::live::LiveEvent::Presence { .. } => "presence", |
|
| 638 | + | crate::live::LiveEvent::Choice { .. } => "choice", |
|
| 639 | + | crate::live::LiveEvent::Landed { .. } => "landed", |
|
| 640 | + | }; |
|
| 641 | + | Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(e).unwrap_or_default())) |
|
| 642 | + | }; |
|
| 643 | + | let first = futures::stream::once(std::future::ready(event(&snapshot))); |
|
| 644 | + | let rest = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| { |
|
| 645 | + | // The seat lives as long as the stream: dropping it leaves the room. |
|
| 646 | + | let _held = &seat; |
|
| 647 | + | std::future::ready(item.ok().map(|e| event(&e))) |
|
| 648 | + | }); |
|
| 649 | + | Ok(axum::response::Sse::new(first.chain(rest)).keep_alive(axum::response::sse::KeepAlive::default())) |
|
| 650 | + | } |
|
| 651 | + | ||
| 652 | + | #[derive(serde::Deserialize)] |
|
| 653 | + | pub struct LiveChoice { |
|
| 654 | + | #[serde(rename = "_csrf")] |
|
| 655 | + | csrf_token: String, |
|
| 656 | + | client: String, |
|
| 657 | + | /// The region's number, or `keep` on a candidate page. |
|
| 658 | + | region: String, |
|
| 659 | + | choice: String, |
|
| 660 | + | #[serde(default)] |
|
| 661 | + | text: String, |
|
| 662 | + | } |
|
| 663 | + | ||
| 664 | + | /// `POST /{owner}/{name}/resolve-live/{bookmark}/{path}`: share a choice |
|
| 665 | + | /// with the others resolving this conflict. The last choice for a region |
|
| 666 | + | /// wins; nothing is committed until someone commits the resolution. |
|
| 667 | + | pub async fn live_choose( |
|
| 668 | + | State(app): State<Shared>, |
|
| 669 | + | viewer: Viewer, |
|
| 670 | + | Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, |
|
| 671 | + | axum::Json(c): axum::Json<LiveChoice>, |
|
| 672 | + | ) -> Result<axum::http::StatusCode, Error> { |
|
| 673 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); |
|
| 674 | + | let me = require(&viewer, &shell)?; |
|
| 675 | + | csrf(&viewer, &c.csrf_token, &shell)?; |
|
| 676 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 677 | + | let valid_region = c.region == "keep" || c.region.parse::<usize>().is_ok(); |
|
| 678 | + | let valid_choice = matches!(c.choice.as_str(), "all" | "none" | "text") |
|
| 679 | + | || c.choice.strip_prefix("side").is_some_and(|i| i.parse::<usize>().is_ok()) |
|
| 680 | + | || (c.region == "keep" && c.choice.parse::<usize>().is_ok()); |
|
| 681 | + | if !valid_region || !valid_choice || c.text.len() > 1 << 20 { |
|
| 682 | + | return Err(Error::bad(shell, "That is not a choice.")); |
|
| 683 | + | } |
|
| 684 | + | let choice = crate::live::Choice { choice: c.choice, text: c.text, by: me.account.username.clone() }; |
|
| 685 | + | Ok(if app.rooms.choose(&room(&row, &bookmark, &path), &c.client, &c.region, choice) { |
|
| 686 | + | axum::http::StatusCode::NO_CONTENT |
|
| 687 | + | } else { |
|
| 688 | + | axum::http::StatusCode::GONE |
|
| 689 | + | }) |
|
| 593 | 690 | } |
modified crates/hub/tests/edits.rs +51 −0
| … | 62 unchanged lines |
||
| 63 | 63 | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 64 | 64 | } |
|
| 65 | 65 | ||
| 66 | + | /// Read server-sent events from `body` until one of `kind` arrives. |
|
| 67 | + | async fn next_event(body: &mut Body, kind: &str) -> String { |
|
| 68 | + | let mut buf = String::new(); |
|
| 69 | + | loop { |
|
| 70 | + | let frame = tokio::time::timeout(std::time::Duration::from_secs(5), body.frame()) |
|
| 71 | + | .await |
|
| 72 | + | .unwrap_or_else(|_| panic!("no {kind} event; got {buf}")) |
|
| 73 | + | .expect("the stream ended") |
|
| 74 | + | .unwrap(); |
|
| 75 | + | if let Ok(data) = frame.into_data() { |
|
| 76 | + | buf.push_str(&String::from_utf8_lossy(&data)); |
|
| 77 | + | } |
|
| 78 | + | while let Some(end) = buf.find("\n\n") { |
|
| 79 | + | let event: String = buf.drain(..end + 2).collect(); |
|
| 80 | + | if event.contains(&format!("event: {kind}\n")) { |
|
| 81 | + | return event; |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | ||
| 66 | 87 | fn csrf_of(page: &str) -> String { |
|
| 67 | 88 | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 68 | 89 | page[i..].split('"').next().unwrap().to_string() |
|
| … | 205 unchanged lines |
||
| 274 | 295 | let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len(); |
|
| 275 | 296 | page.body[i..i + 64].to_string() |
|
| 276 | 297 | }; |
|
| 298 | + | // Live: one tab follows the room, another shares a choice (P1b). |
|
| 299 | + | let live_req = Request::builder() |
|
| 300 | + | .uri("/andy/demo/resolve-live/main/a.txt?client=tab-a") |
|
| 301 | + | .header(header::COOKIE, format!("oxyhub={andy}")) |
|
| 302 | + | .body(Body::empty()) |
|
| 303 | + | .unwrap(); |
|
| 304 | + | let res = oxygenhub::router(app.clone()).oneshot(live_req).await.unwrap(); |
|
| 305 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 306 | + | let mut stream = res.into_body(); |
|
| 307 | + | let snapshot = next_event(&mut stream, "snapshot").await; |
|
| 308 | + | assert!(snapshot.contains("\"present\":[\"andy\"]"), "{snapshot}"); |
|
| 309 | + | let choose = |region: &str, choice: &str| { |
|
| 310 | + | let body = serde_json::json!({ "_csrf": csrf, "client": "tab-b", "region": region, "choice": choice, "text": "" }).to_string(); |
|
| 311 | + | Request::builder() |
|
| 312 | + | .method("POST") |
|
| 313 | + | .uri("/andy/demo/resolve-live/main/a.txt") |
|
| 314 | + | .header(header::COOKIE, format!("oxyhub={andy}")) |
|
| 315 | + | .header(header::CONTENT_TYPE, "application/json") |
|
| 316 | + | .body(Body::from(body)) |
|
| 317 | + | .unwrap() |
|
| 318 | + | }; |
|
| 319 | + | let r = oxygenhub::router(app.clone()).oneshot(choose("1", "side2")).await.unwrap(); |
|
| 320 | + | assert_eq!(r.status(), StatusCode::NO_CONTENT); |
|
| 321 | + | let shared = next_event(&mut stream, "choice").await; |
|
| 322 | + | assert!(shared.contains("\"side2\"") && shared.contains("\"tab-b\""), "{shared}"); |
|
| 323 | + | assert_ne!(oxygenhub::router(app.clone()).oneshot(choose("1", "rm -rf")).await.unwrap().status(), StatusCode::NO_CONTENT); |
|
| 324 | + | ||
| 277 | 325 | let stale = send( |
|
| 278 | 326 | app, |
|
| 279 | 327 | "POST", |
|
| … | 17 unchanged lines |
||
| 297 | 345 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 298 | 346 | let change = r.location.unwrap(); |
|
| 299 | 347 | assert!(change.contains("/andy/demo/change/"), "{change}"); |
|
| 348 | + | let landed = next_event(&mut stream, "landed").await; |
|
| 349 | + | assert!(landed.contains(&change), "the other tab hears the resolution land: {landed}"); |
|
| 350 | + | drop(stream); |
|
| 300 | 351 | assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict")); |
|
| 301 | 352 | assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n"); |
|
| 302 | 353 | let shown = send(app, "GET", &change, Some(&andy), None).await.body; |
|
| … | 119 unchanged lines |
modified docs/spec.md +1 −1
| … | 304 unchanged lines |
||
| 305 | 305 | ||
| 306 | 306 | ### 6.10 Conflicts page |
|
| 307 | 307 | ||
| 308 | - | For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. The change page tags resolution changes. Otherwise the page gives the two-line client recipe. |
|
| 308 | + | For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. Resolving is live: everyone on the same resolver page (a room per repository, bookmark and path, over server-sent events at `/{owner}/{name}/resolve-live/{bookmark}/{path}`) sees who else is there and each other's choices as they are made, region by region, the last choice for a region winning (free text is shared whole, a moment after typing stops); a newcomer gets the choices so far; and when one of them commits, the others are told the resolution landed, with a link to it, and their form is disabled. Rooms are in memory and end when the last viewer leaves. The change page tags resolution changes. Otherwise the page gives the two-line client recipe. |
|
| 309 | 309 | ||
| 310 | 310 | ### 6.11 Trust page |
|
| 311 | 311 | ||
| … | 266 unchanged lines |
modified templates/resolve.html +3 −1
| … | 9 unchanged lines |
||
| 10 | 10 | <p class="small"><a href="{{ vscode }}">Resolve in VS Code</a> <span class="mute">(with the Oxygen extension: it opens your checkout on {{ bookmark }}, or offers to clone it)</span></p> |
|
| 11 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | 12 | {% if let Some(d) = delegation %}<p class="error">{{ d }}</p>{% endif %} |
|
| 13 | - | <form method="post" action="{{ repo.url }}/resolve/{{ bookmark }}/{{ path }}"> |
|
| 13 | + | <p class="small" data-live-present hidden></p> |
|
| 14 | + | <div class="strip" data-live-landed hidden></div> |
|
| 15 | + | <form method="post" action="{{ repo.url }}/resolve/{{ bookmark }}/{{ path }}" data-resolve-live="{{ repo.url }}/resolve-live/{{ bookmark }}/{{ path }}" data-csrf="{{ shell.csrf }}"> |
|
| 14 | 16 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 15 | 17 | <input type="hidden" name="state" value="{{ state }}"> |
|
| 16 | 18 | {% for p in pieces %} |
|
| … | 26 unchanged lines |
modified templates/resolve_name.html +3 −1
| … | 9 unchanged lines |
||
| 10 | 10 | <p class="small"><a href="{{ vscode }}">Resolve in VS Code</a> <span class="mute">(with the Oxygen extension: it opens your checkout on {{ bookmark }}, or offers to clone it)</span></p> |
|
| 11 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | 12 | {% if let Some(d) = delegation %}<p class="error">{{ d }}</p>{% endif %} |
|
| 13 | - | <form method="post" action="{{ repo.url }}/resolve/{{ bookmark }}/{{ path }}"> |
|
| 13 | + | <p class="small" data-live-present hidden></p> |
|
| 14 | + | <div class="strip" data-live-landed hidden></div> |
|
| 15 | + | <form method="post" action="{{ repo.url }}/resolve/{{ bookmark }}/{{ path }}" data-resolve-live="{{ repo.url }}/resolve-live/{{ bookmark }}/{{ path }}" data-csrf="{{ shell.csrf }}"> |
|
| 14 | 16 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 15 | 17 | <input type="hidden" name="state" value="{{ state }}"> |
|
| 16 | 18 | {% for c in candidates %} |
|
| … | 9 unchanged lines |