OXYGENHUB

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