//! The conflict resolver (hub spec §6.10): a conflicted text file of a //! bookmark, region by region. For each region the user keeps one side, //! every side in order, none, or text of their own; the file is put back //! together around the unconflicted lines and committed through the //! web-edit path, which makes it a `resolution` change depending on every //! change that contributed to the conflict. use super::edit::{after_web_commit, signer_for}; use super::settings::{csrf, require}; use super::{lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::repos::edits; use crate::repos::view::{self, FileBody}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, State}; use axum::response::{IntoResponse, Redirect, Response}; use oxy_core::repo::FileEdit; use std::collections::HashMap; /// "Resolve in VS Code": the link oxygen-vscode handles (its README). pub fn vscode_link(app: &Shared, owner: &str, name: &str, bookmark: &str, path: &str) -> String { use super::settings::urlencode; let url = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/')); format!("vscode://oxygenhub.oxygen-vscode/resolve?url={}&bookmark={}&path={}", urlencode(&url), urlencode(bookmark), urlencode(path)) } /// A piece of a file rendered with `oxy` conflict markers (spec §9.5). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Piece { /// Lines outside any conflict, kept as they are. Plain(String), Region(Region), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Region { /// 1-based, as the markers number it. pub n: usize, pub sides: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Side { /// "change 3f2a…", or "deleted by change …". pub label: String, /// The side's lines, each ending in `\n`. pub text: String, /// A deletion side: `text` is what was deleted, shown for context; /// keeping this side keeps the deletion, so it contributes nothing. pub deletion: bool, } impl Side { /// What keeping this side puts in the file. pub fn kept(&self) -> &str { if self.deletion { "" } else { &self.text } } } impl Region { /// Every side in order: what keeping all of them puts in the file. pub fn all(&self) -> String { self.sides.iter().map(Side::kept).collect() } } /// Split a marked rendering into plain text and regions. `None` when the /// markers are malformed (the page then offers the plain editor). pub fn parse(marked: &str) -> Option> { let mut out = Vec::new(); let mut plain = String::new(); let mut region: Option = None; let mut lines = marked.split_inclusive('\n').peekable(); while let Some(line) = lines.next() { let bare = line.trim_end_matches('\n'); if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") { if region.is_some() { return None; } if !plain.is_empty() { out.push(Piece::Plain(std::mem::take(&mut plain))); } let (n, label) = header(rest)?; let mut side = Side { label, text: String::new(), deletion: false }; if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") { lines.next(); side.deletion = true; } region = Some(Region { n, sides: vec![side] }); } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") { let r = region.as_mut()?; let (n, label) = header(rest)?; if n != r.n { return None; } r.sides.push(Side { label, text: String::new(), deletion: false }); } else if bare.starts_with(">>>>>>> oxy conflict ") { out.push(Piece::Region(region.take()?)); } else { match region.as_mut() { Some(r) => r.sides.last_mut()?.text.push_str(line), None => plain.push_str(line), } } } if region.is_some() { return None; } if !plain.is_empty() { out.push(Piece::Plain(plain)); } Some(out) } /// `N of T, side I of K: LABEL` → (N, LABEL). fn header(rest: &str) -> Option<(usize, String)> { let (n, tail) = rest.split_once(" of ")?; let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default(); Some((n.trim().parse().ok()?, label)) } /// A user's choice for one region. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Choice { Side(usize), All, None, Text(String), } /// The file with each region replaced by what was chosen for it; every /// region needs a choice. pub fn assemble(pieces: &[Piece], choices: &HashMap) -> Result { let mut out = String::new(); for p in pieces { match p { Piece::Plain(t) => out.push_str(t), Piece::Region(r) => match choices.get(&r.n) { Some(Choice::Side(i)) => { out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept()) } Some(Choice::All) => out.push_str(&r.all()), Some(Choice::None) => {} Some(Choice::Text(t)) => { let t = t.replace("\r\n", "\n"); out.push_str(&t); if !t.is_empty() && !t.ends_with('\n') { out.push('\n'); } } None => return Err(format!("choose how to resolve conflict {}", r.n)), }, } } Ok(out) } /// One of the entries a name conflict shows at `NAME.oxy-conflict-N`. #[derive(Clone, Debug)] pub struct Candidate { pub path: String, pub content: Vec, /// The start of a text candidate, for the page. pub preview: Option, } /// The candidates of a name conflict at `path` in a loaded state: the /// files the tree shows as `path.oxy-conflict-N`. `None` when `path` has no /// name conflict; an error for one between directories or links. fn name_candidates(r: &oxy_core::repo::Repo, snapshot: &oxy_core::state::Snapshot, path: &str) -> Result>, String> { use oxy_core::objects::NodeKind; use oxy_core::state::FileContent; use oxy_core::tree::TreeConflict; let by_node = snapshot.tree.by_node(); for c in &snapshot.tree.conflicts { let TreeConflict::Name { parent, name, nodes } = c else { continue }; let full = if parent.is_root() { name.clone() } else { match by_node.get(parent) { Some(e) => format!("{}/{name}", e.path), None => continue, } }; if full != path { continue; } let mut out = Vec::new(); for n in nodes { let Some(e) = by_node.get(n) else { continue }; let f = snapshot.files.get(&e.path).ok_or("a candidate is missing")?; if f.kind != NodeKind::File { return Err("This conflict is between directories or links; resolve it from a client.".into()); } let content = match &f.content { FileContent::Text { bytes, .. } => bytes.clone(), FileContent::Opaque { blobs } => match blobs.first() { Some(b) => r.read_blob(b).map_err(|e| e.to_string())?, None => vec![], }, FileContent::Dir => continue, }; let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::>().join("\n")); out.push(Candidate { path: e.path.clone(), content, preview }); } out.sort_by(|a, b| a.path.cmp(&b.path)); return Ok(Some(out)); } Ok(None) } /// The versions of a binary file with a replace conflict at `path`, each /// labelled with the change that wrote it. fn replace_candidates( r: &oxy_core::repo::Repo, state: &oxy_core::state::State, snapshot: &oxy_core::state::Snapshot, path: &str, ) -> Option> { use oxy_core::state::FileContent; let f = snapshot.files.get(path)?; let FileContent::Opaque { blobs } = &f.content else { return None }; if blobs.len() < 2 || f.kind != oxy_core::objects::NodeKind::File { return None; } let writers = state.live_replaces(&f.node); let mut out = Vec::new(); for (i, b) in blobs.iter().enumerate() { let content = r.read_blob(b).ok()?; let by = writers.iter().find(|(_, n)| n == b).map(|(h, _)| state.label(h)).unwrap_or_default(); let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::>().join("\n")); out.push(Candidate { path: format!("version {} from change {by}", i + 1), content, preview }); } Some(out) } /// What resolving `path` on a bookmark means. enum Target { /// A conflicted text file, rendered with markers. Lines(String), /// A name conflict: keep one candidate at `path`. Name(Vec), /// A replace conflict of a binary file: keep one of its versions. Replace(Vec), } #[derive(Template)] #[template(path = "resolve_name.html")] pub struct NamePage { shell: Shell, repo: RepoShell, bookmark: String, path: String, state: String, candidates: Vec, /// Whether the candidates are versions of one file (a replace /// conflict) rather than files wanting one name. versions: bool, vscode: String, delegation: Option, error: Option, message: String, } #[derive(Template)] #[template(path = "resolve.html")] pub struct ResolvePage { shell: Shell, repo: RepoShell, bookmark: String, path: String, state: String, pieces: Vec, /// The link that hands this conflict to oxygen-vscode. vscode: String, delegation: Option, error: Option, message: String, } /// The bookmark's state and what resolving `path` there means. async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, Target), Error> { let limit = app.config.limits.render_limit.0; let (b, p) = (bookmark.to_string(), path.to_string()); let found = app .repos .read(repo, move |r, cache| { let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) }; let heads = r.store().get_state(&st)?.heads; let loaded = cache.load(r, &heads)?; let names = name_candidates(r, &loaded.snapshot, &p); let versions = replace_candidates(r, &loaded.state, &loaded.snapshot, &p); let file = if names.as_ref().is_ok_and(|n| n.is_some()) { None } else { let target = view::resolve(r, cache, &b)?; Some(view::file(r, cache, target, &p, limit)) }; Ok(Some((st.to_hex(), names, file, versions))) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let Some((state, names, file, versions)) = found else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")); }; if let Some(v) = versions { return Ok((state, Target::Replace(v))); } match names { Ok(Some(c)) => return Ok((state, Target::Name(c))), Err(msg) => return Err(Error::bad(shell.clone(), &msg)), Ok(None) => {} } let file = match file { Some(Ok(f)) => f, Some(Err(e)) => return Err(Error::from_repo(shell.clone(), e)), None => return Err(Error::bad(shell.clone(), "Nothing to resolve there.")), }; match file.body { FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))), FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")), _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")), } } #[allow(clippy::too_many_arguments)] async fn page( app: &Shared, viewer: &Viewer, owner: &str, name: &str, bookmark: &str, path: &str, error: Option, message: Option, ) -> Result, Error> { let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer); let me = require(viewer, &shell)?; let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?; let (state, target) = load(app, &shell, &repo, bookmark, path).await?; let text = match target { Target::Lines(t) => t, Target::Name(_) | Target::Replace(_) => return Err(Error::bad(shell, "That conflict is resolved by keeping a candidate.")), }; let Some(pieces) = parse(&text) else { return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor.")); }; let delegation = signer_for(app, me.account.id).await.err(); Ok(Page(ResolvePage { shell, repo: RepoShell::new(app, &row, "conflicts").with_role(role), bookmark: bookmark.to_string(), path: path.to_string(), state, pieces, vscode: vscode_link(app, owner, name, bookmark, path), delegation, error, message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")), })) } pub async fn form( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; match load(&app, &shell, &repo, &bookmark, &path).await?.1 { // Malformed markers: the plain editor still resolves the file. Target::Lines(text) if parse(&text).is_none() => { Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response()) } Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()), Target::Name(_) | Target::Replace(_) => { Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()) } } } #[allow(clippy::too_many_arguments)] async fn name_page( app: &Shared, viewer: &Viewer, owner: &str, name: &str, bookmark: &str, path: &str, error: Option, message: Option, ) -> Result, Error> { let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer); let me = require(viewer, &shell)?; let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?; let (state, target) = load(app, &shell, &repo, bookmark, path).await?; let (candidates, versions) = match target { Target::Name(c) => (c, false), Target::Replace(c) => (c, true), Target::Lines(_) => return Err(Error::bad(shell, "That conflict is resolved region by region.")), }; let delegation = signer_for(app, me.account.id).await.err(); Ok(Page(NamePage { shell, repo: RepoShell::new(app, &row, "conflicts").with_role(role), bookmark: bookmark.to_string(), path: path.to_string(), state, candidates, versions, vscode: vscode_link(app, owner, name, bookmark, path), delegation, error, message: message.unwrap_or_else(|| { if versions { format!("Resolve the conflicting versions of {path}") } else { format!("Resolve the name conflict at {path}") } }), })) } pub async fn save( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, Form(f): Form>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default(); let again = |msg: String| { let (app, viewer, owner, name, bookmark, path, message) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone()); async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) } }; let (state, target) = load(&app, &shell, &repo, &bookmark, &path).await?; let stale = f.get("state") != Some(&state); let text = match target { Target::Lines(t) => t, Target::Name(candidates) | Target::Replace(candidates) if candidates.is_empty() => { return again("There are no candidates to keep.".into()).await; } Target::Replace(candidates) => { let again = |msg: String| { let (app, viewer, owner, name, bookmark, path, message) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone()); async move { name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)) .await .map(IntoResponse::into_response) } }; if stale { return again(format!("{bookmark} has moved since this page was loaded; the versions below are as they are now.")).await; } let Some(keep) = f.get("keep").and_then(|k| k.parse::().ok()).and_then(|k| candidates.get(k)) else { return again("Choose the version to keep.".into()).await; }; if message.is_empty() { return again("Describe the resolution.".into()).await; } let signer = match signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return again(msg).await, }; // Writing the kept version at the path settles the conflict. let edit = vec![FileEdit { path: path.clone(), content: Some(keep.content.clone()) }]; let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await { Ok(d) => d, Err(e) => return again(e.to_string()).await, }; after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); return Ok(Redirect::to(&url).into_response()); } Target::Name(candidates) => { let again = |msg: String| { let (app, viewer, owner, name, bookmark, path, message) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone()); async move { name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)) .await .map(IntoResponse::into_response) } }; if stale { return again(format!("{bookmark} has moved since this page was loaded; the candidates below are as they are now.")).await; } let Some(keep) = f.get("keep").and_then(|k| k.parse::().ok()).and_then(|k| candidates.get(k)) else { return again("Choose the candidate to keep.".into()).await; }; if message.is_empty() { return again("Describe the resolution.".into()).await; } let signer = match signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return again(msg).await, }; // The kept candidate's content at the name, every candidate // gone: the snapshot pairs the kept one with the name as a // move, so it keeps its identity. let mut edit: Vec = candidates.iter().map(|c| FileEdit { path: c.path.clone(), content: None }).collect(); edit.push(FileEdit { path: path.clone(), content: Some(keep.content.clone()) }); let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await { Ok(d) => d, Err(e) => return again(e.to_string()).await, }; after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); return Ok(Redirect::to(&url).into_response()); } }; if stale { return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await; } let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await }; let mut choices = HashMap::new(); for p in &pieces { if let Piece::Region(r) = p { let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) { Some("all") => Choice::All, Some("none") => Choice::None, Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()), Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) { Some(i) => Choice::Side(i), None => continue, }, None => continue, }; choices.insert(r.n, choice); } } let content = match assemble(&pieces, &choices) { Ok(c) => c, Err(msg) => return again(msg).await, }; if message.is_empty() { return again("Describe the resolution.".into()).await; } let signer = match signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return again(msg).await, }; let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }]; let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await { Ok(d) => d, Err(e) => return again(e.to_string()).await, }; after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; let url = format!("/{owner}/{name}/change/{}", done.change.to_hex()); app.rooms.landed(&room(&row, &bookmark, &path), &url, &me.account.username); Ok(Redirect::to(&url).into_response()) } #[cfg(test)] mod tests { use super::*; const MARKED: &str = "a\n<<<<<<< oxy conflict 1 of 2, side 1 of 2: change 0202\nfrom-2\n======= oxy conflict 1 of 2, side 2 of 2: change 0303\nfrom-3\nmore-3\n>>>>>>> oxy conflict 1 of 2 end\nb\n<<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n-------\ngone\n======= oxy conflict 2 of 2, side 2 of 2: change 0505\nadded\n>>>>>>> oxy conflict 2 of 2 end\nc\n"; #[test] fn markers_parse_into_regions_and_sides() { let p = parse(MARKED).unwrap(); assert_eq!(p.len(), 5); assert_eq!(p[0], Piece::Plain("a\n".into())); let Piece::Region(r1) = &p[1] else { panic!() }; assert_eq!((r1.n, r1.sides.len()), (1, 2)); assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n")); assert_eq!(r1.sides[1].text, "from-3\nmore-3\n"); let Piece::Region(r2) = &p[3] else { panic!() }; assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty()); assert_eq!(r2.sides[0].label, "deleted by change 0404"); assert_eq!(p[4], Piece::Plain("c\n".into())); assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none()); assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]); } #[test] fn choices_assemble_the_file() { let p = parse(MARKED).unwrap(); let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)])); assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing"); assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n"); assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line"); assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3")); assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2")); } } // -------------------------------------------------------------- live (P1b) fn room(row: &crate::db::repos::RepoRow, bookmark: &str, path: &str) -> crate::live::RoomKey { crate::live::RoomKey { repo_id: row.id, bookmark: bookmark.to_string(), path: path.to_string() } } #[derive(serde::Deserialize)] pub struct LiveQuery { /// This browser tab's id, so a tab ignores its own choices. client: String, } /// `GET /{owner}/{name}/resolve-live/{bookmark}/{path}?client=…`: server-sent /// events for everyone resolving this conflict (hub spec §6.10): a snapshot /// of who is here and what they chose, then presence, choices, and the /// resolution when it lands. Writers only, as the resolver. pub async fn live_events( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, axum::extract::Query(q): axum::extract::Query, ) -> Result>>, Error> { use futures::StreamExt; let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); let me = require(&viewer, &shell)?; let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let client: String = q.client.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(64).collect(); if client.is_empty() { return Err(Error::bad(shell, "A live view needs a client id.")); } let key = room(&row, &bookmark, &path); let (rx, snapshot) = app.rooms.join(key.clone(), &client, &me.account.username); let seat = app.rooms.seat(key, &client); let event = |e: &crate::live::LiveEvent| { let kind = match e { crate::live::LiveEvent::Snapshot { .. } => "snapshot", crate::live::LiveEvent::Presence { .. } => "presence", crate::live::LiveEvent::Choice { .. } => "choice", crate::live::LiveEvent::Landed { .. } => "landed", }; Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(e).unwrap_or_default())) }; let first = futures::stream::once(std::future::ready(event(&snapshot))); let rest = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| { // The seat lives as long as the stream: dropping it leaves the room. let _held = &seat; std::future::ready(item.ok().map(|e| event(&e))) }); Ok(axum::response::Sse::new(first.chain(rest)).keep_alive(axum::response::sse::KeepAlive::default())) } #[derive(serde::Deserialize)] pub struct LiveChoice { #[serde(rename = "_csrf")] csrf_token: String, client: String, /// The region's number, or `keep` on a candidate page. region: String, choice: String, #[serde(default)] text: String, } /// `POST /{owner}/{name}/resolve-live/{bookmark}/{path}`: share a choice /// with the others resolving this conflict. The last choice for a region /// wins; nothing is committed until someone commits the resolution. pub async fn live_choose( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, axum::Json(c): axum::Json, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &c.csrf_token, &shell)?; let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let valid_region = c.region == "keep" || c.region.parse::().is_ok(); let valid_choice = matches!(c.choice.as_str(), "all" | "none" | "text") || c.choice.strip_prefix("side").is_some_and(|i| i.parse::().is_ok()) || (c.region == "keep" && c.choice.parse::().is_ok()); if !valid_region || !valid_choice || c.text.len() > 1 << 20 { return Err(Error::bad(shell, "That is not a choice.")); } let choice = crate::live::Choice { choice: c.choice, text: c.text, by: me.account.username.clone() }; Ok(if app.rooms.choose(&room(&row, &bookmark, &path), &c.client, &c.region, choice) { axum::http::StatusCode::NO_CONTENT } else { axum::http::StatusCode::GONE }) }