OXYGENHUB

Coverage / crates/hub/src/web/resolve.rs 87.2%

421 of 483 lines run, 59 of 115 functions · run #536 on main · run missed

1
//! The conflict resolver (hub spec §6.10): a conflicted text file of a
2
//! bookmark, region by region. For each region the user keeps one side,
3
//! every side in order, none, or text of their own; the file is put back
4
//! together around the unconflicted lines and committed through the
5
//! web-edit path, which makes it a `resolution` change depending on every
6
//! change that contributed to the conflict.
7
8
use super::edit::{after_web_commit, signer_for};
9
use super::settings::{csrf, require};
10
use super::{lookup_role, Error, Page, RepoShell, Shell};
11
use crate::auth::{Access, Viewer};
12
use crate::repos::edits;
13
use crate::repos::view::{self, FileBody};
14
use crate::Shared;
15
use askama::Template;
16
use axum::extract::{Form, Path, State};
17
use axum::response::{IntoResponse, Redirect, Response};
18
use oxy_core::repo::FileEdit;
19
use std::collections::HashMap;
20
21
/// "Resolve in VS Code": the link oxygen-vscode handles (its README).
226×
pub fn vscode_link(app: &Shared, owner: &str, name: &str, bookmark: &str, path: &str) -> String {
23
    use super::settings::urlencode;
246×
    let url = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
256×
    format!("vscode://oxygenhub.oxygen-vscode/resolve?url={}&bookmark={}&path={}", urlencode(&url), urlencode(bookmark), urlencode(path))
266×
}
27
28
/// A piece of a file rendered with `oxy` conflict markers (spec §9.5).
29
#[derive(Clone, Debug, PartialEq, Eq)]
30
pub enum Piece {
31
    /// Lines outside any conflict, kept as they are.
32
    Plain(String),
33
    Region(Region),
34
}
35
36
#[derive(Clone, Debug, PartialEq, Eq)]
37
pub struct Region {
38
    /// 1-based, as the markers number it.
39
    pub n: usize,
40
    pub sides: Vec<Side>,
41
}
42
43
#[derive(Clone, Debug, PartialEq, Eq)]
44
pub struct Side {
45
    /// "change 3f2a…", or "deleted by change …".
46
    pub label: String,
47
    /// The side's lines, each ending in `\n`.
48
    pub text: String,
49
    /// A deletion side: `text` is what was deleted, shown for context;
50
    /// keeping this side keeps the deletion, so it contributes nothing.
51
    pub deletion: bool,
52
}
53
54
impl Side {
55
    /// What keeping this side puts in the file.
5622×
    pub fn kept(&self) -> &str {
5722×
        if self.deletion {
582×
            ""
59
        } else {
6020×
            &self.text
61
        }
6222×
    }
63
}
64
65
impl Region {
66
    /// Every side in order: what keeping all of them puts in the file.
679×
    pub fn all(&self) -> String {
689×
        self.sides.iter().map(Side::kept).collect()
699×
    }
70
}
71
72
/// Split a marked rendering into plain text and regions. `None` when the
73
/// markers are malformed (the page then offers the plain editor).
7413×
pub fn parse(marked: &str) -> Option<Vec<Piece>> {
7513×
    let mut out = Vec::new();
7613×
    let mut plain = String::new();
7713×
    let mut region: Option<Region> = None;
7813×
    let mut lines = marked.split_inclusive('\n').peekable();
79104×
    while let Some(line) = lines.next() {
8091×
        let bare = line.trim_end_matches('\n');
8191×
        if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") {
8214×
            if region.is_some() {
830×
                return None;
8414×
            }
8514×
            if !plain.is_empty() {
864×
                out.push(Piece::Plain(std::mem::take(&mut plain)));
8710×
            }
8814×
            let (n, label) = header(rest)?;
8914×
            let mut side = Side { label, text: String::new(), deletion: false };
9014×
            if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") {
912×
                lines.next();
922×
                side.deletion = true;
9312×
            }
9414×
            region = Some(Region { n, sides: vec![side] });
9577×
        } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") {
9613×
            let r = region.as_mut()?;
9713×
            let (n, label) = header(rest)?;
9813×
            if n != r.n {
990×
                return None;
10013×
            }
10113×
            r.sides.push(Side { label, text: String::new(), deletion: false });
10264×
        } else if bare.starts_with(">>>>>>> oxy conflict ") {
10313×
            out.push(Piece::Region(region.take()?));
104
        } else {
10551×
            match region.as_mut() {
10629×
                Some(r) => r.sides.last_mut()?.text.push_str(line),
10722×
                None => plain.push_str(line),
108
            }
109
        }
110
    }
11113×
    if region.is_some() {
1121×
        return None;
11312×
    }
11412×
    if !plain.is_empty() {
11512×
        out.push(Piece::Plain(plain));
11612×
    }
11712×
    Some(out)
11813×
}
119
120
/// `N of T, side I of K: LABEL` → (N, LABEL).
12127×
fn header(rest: &str) -> Option<(usize, String)> {
12227×
    let (n, tail) = rest.split_once(" of ")?;
12327×
    let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default();
12427×
    Some((n.trim().parse().ok()?, label))
12527×
}
126
127
/// A user's choice for one region.
128
#[derive(Clone, Debug, PartialEq, Eq)]
129
pub enum Choice {
130
    Side(usize),
131
    All,
132
    None,
133
    Text(String),
134
}
135
136
/// The file with each region replaced by what was chosen for it; every
137
/// region needs a choice.
1388×
pub fn assemble(pieces: &[Piece], choices: &HashMap<usize, Choice>) -> Result<String, String> {
1398×
    let mut out = String::new();
14026×
    for p in pieces {
14126×
        match p {
14214×
            Piece::Plain(t) => out.push_str(t),
14312×
            Piece::Region(r) => match choices.get(&r.n) {
1444×
                Some(Choice::Side(i)) => {
1454×
                    out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept())
146
                }
1471×
                Some(Choice::All) => out.push_str(&r.all()),
1482×
                Some(Choice::None) => {}
1493×
                Some(Choice::Text(t)) => {
1503×
                    let t = t.replace("\r\n", "\n");
1513×
                    out.push_str(&t);
1523×
                    if !t.is_empty() && !t.ends_with('\n') {
1533×
                        out.push('\n');
1543×
                    }
155
                }
1562×
                None => return Err(format!("choose how to resolve conflict {}", r.n)),
157
            },
158
        }
159
    }
1605×
    Ok(out)
1618×
}
162
163
/// One of the entries a name conflict shows at `NAME.oxy-conflict-N`.
164
#[derive(Clone, Debug)]
165
pub struct Candidate {
166
    pub path: String,
167
    pub content: Vec<u8>,
168
    /// The start of a text candidate, for the page.
169
    pub preview: Option<String>,
170
}
171
172
/// The candidates of a name conflict at `path` in a loaded state: the
173
/// files the tree shows as `path.oxy-conflict-N`. `None` when `path` has no
174
/// name conflict; an error for one between directories or links.
17516×
fn name_candidates(r: &oxy_core::repo::Repo, snapshot: &oxy_core::state::Snapshot, path: &str) -> Result<Option<Vec<Candidate>>, String> {
176
    use oxy_core::objects::NodeKind;
177
    use oxy_core::state::FileContent;
178
    use oxy_core::tree::TreeConflict;
17916×
    let by_node = snapshot.tree.by_node();
18016×
    for c in &snapshot.tree.conflicts {
1813×
        let TreeConflict::Name { parent, name, nodes } = c else { continue };
1823×
        let full = if parent.is_root() {
1833×
            name.clone()
184
        } else {
1850×
            match by_node.get(parent) {
1860×
                Some(e) => format!("{}/{name}", e.path),
1870×
                None => continue,
188
            }
189
        };
1903×
        if full != path {
1910×
            continue;
1923×
        }
1933×
        let mut out = Vec::new();
1946×
        for n in nodes {
1956×
            let Some(e) = by_node.get(n) else { continue };
1966×
            let f = snapshot.files.get(&e.path).ok_or("a candidate is missing")?;
1976×
            if f.kind != NodeKind::File {
1980×
                return Err("This conflict is between directories or links; resolve it from a client.".into());
1996×
            }
2006×
            let content = match &f.content {
2016×
                FileContent::Text { bytes, .. } => bytes.clone(),
2020×
                FileContent::Opaque { blobs } => match blobs.first() {
2030×
                    Some(b) => r.read_blob(b).map_err(|e| e.to_string())?,
2040×
                    None => vec![],
205
                },
2060×
                FileContent::Dir => continue,
207
            };
2086×
            let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
2096×
            out.push(Candidate { path: e.path.clone(), content, preview });
210
        }
2113×
        out.sort_by(|a, b| a.path.cmp(&b.path));
2123×
        return Ok(Some(out));
213
    }
21413×
    Ok(None)
21516×
}
216
217
/// The versions of a binary file with a replace conflict at `path`, each
218
/// labelled with the change that wrote it.
21916×
fn replace_candidates(
22016×
    r: &oxy_core::repo::Repo,
22116×
    state: &oxy_core::state::State,
22216×
    snapshot: &oxy_core::state::Snapshot,
22316×
    path: &str,
22416×
) -> Option<Vec<Candidate>> {
225
    use oxy_core::state::FileContent;
22616×
    let f = snapshot.files.get(path)?;
22713×
    let FileContent::Opaque { blobs } = &f.content else { return None };
2283×
    if blobs.len() < 2 || f.kind != oxy_core::objects::NodeKind::File {
2290×
        return None;
2303×
    }
2313×
    let writers = state.live_replaces(&f.node);
2323×
    let mut out = Vec::new();
2336×
    for (i, b) in blobs.iter().enumerate() {
2346×
        let content = r.read_blob(b).ok()?;
2359×
        let by = writers.iter().find(|(_, n)| n == b).map(|(h, _)| state.label(h)).unwrap_or_default();
2366×
        let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
2376×
        out.push(Candidate { path: format!("version {} from change {by}", i + 1), content, preview });
238
    }
2393×
    Some(out)
24016×
}
241
242
/// What resolving `path` on a bookmark means.
243
enum Target {
244
    /// A conflicted text file, rendered with markers.
245
    Lines(String),
246
    /// A name conflict: keep one candidate at `path`.
247
    Name(Vec<Candidate>),
248
    /// A replace conflict of a binary file: keep one of its versions.
249
    Replace(Vec<Candidate>),
250
}
251
252
#[derive(Template)]
253
#[template(path = "resolve_name.html")]
254
pub struct NamePage {
255
    shell: Shell,
256
    repo: RepoShell,
257
    bookmark: String,
258
    path: String,
259
    state: String,
260
    candidates: Vec<Candidate>,
261
    /// Whether the candidates are versions of one file (a replace
262
    /// conflict) rather than files wanting one name.
263
    versions: bool,
264
    vscode: String,
265
    delegation: Option<String>,
266
    error: Option<String>,
267
    message: String,
268
}
269
270
#[derive(Template)]
271
#[template(path = "resolve.html")]
272
pub struct ResolvePage {
273
    shell: Shell,
274
    repo: RepoShell,
275
    bookmark: String,
276
    path: String,
277
    state: String,
278
    pieces: Vec<Piece>,
279
    /// The link that hands this conflict to oxygen-vscode.
280
    vscode: String,
281
    delegation: Option<String>,
282
    error: Option<String>,
283
    message: String,
284
}
285
286
/// The bookmark's state and what resolving `path` there means.
28716×
async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, Target), Error> {
28816×
    let limit = app.config.limits.render_limit.0;
28916×
    let (b, p) = (bookmark.to_string(), path.to_string());
29016×
    let found = app
29116×
        .repos
29216×
        .read(repo, move |r, cache| {
29316×
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
29416×
            let heads = r.store().get_state(&st)?.heads;
29516×
            let loaded = cache.load(r, &heads)?;
29616×
            let names = name_candidates(r, &loaded.snapshot, &p);
29716×
            let versions = replace_candidates(r, &loaded.state, &loaded.snapshot, &p);
29816×
            let file = if names.as_ref().is_ok_and(|n| n.is_some()) {
2993×
                None
300
            } else {
30113×
                let target = view::resolve(r, cache, &b)?;
30213×
                Some(view::file(r, cache, target, &p, limit))
303
            };
30416×
            Ok(Some((st.to_hex(), names, file, versions)))
30516×
        })
30616×
        .await
30716×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
30816×
    let Some((state, names, file, versions)) = found else {
3090×
        return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark."));
310
    };
31116×
    if let Some(v) = versions {
3123×
        return Ok((state, Target::Replace(v)));
31313×
    }
31413×
    match names {
3153×
        Ok(Some(c)) => return Ok((state, Target::Name(c))),
3160×
        Err(msg) => return Err(Error::bad(shell.clone(), &msg)),
31710×
        Ok(None) => {}
318
    }
31910×
    let file = match file {
32010×
        Some(Ok(f)) => f,
3210×
        Some(Err(e)) => return Err(Error::from_repo(shell.clone(), e)),
3220×
        None => return Err(Error::bad(shell.clone(), "Nothing to resolve there.")),
323
    };
32410×
    match file.body {
32510×
        FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))),
3260×
        FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")),
3270×
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")),
328
    }
32916×
}
330
331
#[allow(clippy::too_many_arguments)]
3324×
async fn page(
3334×
    app: &Shared,
3344×
    viewer: &Viewer,
3354×
    owner: &str,
3364×
    name: &str,
3374×
    bookmark: &str,
3384×
    path: &str,
3394×
    error: Option<String>,
3404×
    message: Option<String>,
3414×
) -> Result<Page<ResolvePage>, Error> {
3424×
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
3434×
    let me = require(viewer, &shell)?;
3444×
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
3454×
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
3464×
    let text = match target {
3474×
        Target::Lines(t) => t,
3480×
        Target::Name(_) | Target::Replace(_) => return Err(Error::bad(shell, "That conflict is resolved by keeping a candidate.")),
349
    };
3504×
    let Some(pieces) = parse(&text) else {
3510×
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
352
    };
3534×
    let delegation = signer_for(app, me.account.id).await.err();
354
    Ok(Page(ResolvePage {
3554×
        shell,
3564×
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
3574×
        bookmark: bookmark.to_string(),
3584×
        path: path.to_string(),
3594×
        state,
3604×
        pieces,
3614×
        vscode: vscode_link(app, owner, name, bookmark, path),
3624×
        delegation,
3634×
        error,
3644×
        message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")),
365
    }))
3664×
}
367
3684×
pub async fn form(
3694×
    State(app): State<Shared>,
3704×
    viewer: Viewer,
3714×
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
3724×
) -> Result<Response, Error> {
3734×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
3744×
    let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
3754×
    match load(&app, &shell, &repo, &bookmark, &path).await?.1 {
376
        // Malformed markers: the plain editor still resolves the file.
3772×
        Target::Lines(text) if parse(&text).is_none() => {
3780×
            Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response())
379
        }
3802×
        Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
381
        Target::Name(_) | Target::Replace(_) => {
3822×
            Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response())
383
        }
384
    }
3854×
}
386
387
#[allow(clippy::too_many_arguments)]
3882×
async fn name_page(
3892×
    app: &Shared,
3902×
    viewer: &Viewer,
3912×
    owner: &str,
3922×
    name: &str,
3932×
    bookmark: &str,
3942×
    path: &str,
3952×
    error: Option<String>,
3962×
    message: Option<String>,
3972×
) -> Result<Page<NamePage>, Error> {
3982×
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
3992×
    let me = require(viewer, &shell)?;
4002×
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
4012×
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
4022×
    let (candidates, versions) = match target {
4031×
        Target::Name(c) => (c, false),
4041×
        Target::Replace(c) => (c, true),
4050×
        Target::Lines(_) => return Err(Error::bad(shell, "That conflict is resolved region by region.")),
406
    };
4072×
    let delegation = signer_for(app, me.account.id).await.err();
408
    Ok(Page(NamePage {
4092×
        shell,
4102×
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
4112×
        bookmark: bookmark.to_string(),
4122×
        path: path.to_string(),
4132×
        state,
4142×
        candidates,
4152×
        versions,
4162×
        vscode: vscode_link(app, owner, name, bookmark, path),
4172×
        delegation,
4182×
        error,
4192×
        message: message.unwrap_or_else(|| {
4202×
            if versions {
4211×
                format!("Resolve the conflicting versions of {path}")
422
            } else {
4231×
                format!("Resolve the name conflict at {path}")
424
            }
4252×
        }),
426
    }))
4272×
}
428
4296×
pub async fn save(
4306×
    State(app): State<Shared>,
4316×
    viewer: Viewer,
4326×
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
4336×
    Form(f): Form<HashMap<String, String>>,
4346×
) -> Result<Response, Error> {
4356×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
4366×
    let me = require(&viewer, &shell)?;
4376×
    csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?;
4386×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
4396×
    let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default();
4406×
    let again = |msg: String| {
4412×
        let (app, viewer, owner, name, bookmark, path, message) =
4422×
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
4432×
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
4442×
    };
4456×
    let (state, target) = load(&app, &shell, &repo, &bookmark, &path).await?;
4466×
    let stale = f.get("state") != Some(&state);
4474×
    let text = match target {
4484×
        Target::Lines(t) => t,
4491×
        Target::Name(candidates) | Target::Replace(candidates) if candidates.is_empty() => {
4500×
            return again("There are no candidates to keep.".into()).await;
451
        }
4521×
        Target::Replace(candidates) => {
4531×
            let again = |msg: String| {
4540×
                let (app, viewer, owner, name, bookmark, path, message) =
4550×
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
4560×
                async move {
4570×
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
4580×
                        .await
4590×
                        .map(IntoResponse::into_response)
4600×
                }
4610×
            };
4621×
            if stale {
4630×
                return again(format!("{bookmark} has moved since this page was loaded; the versions below are as they are now.")).await;
4641×
            }
4651×
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
4660×
                return again("Choose the version to keep.".into()).await;
467
            };
4681×
            if message.is_empty() {
4690×
                return again("Describe the resolution.".into()).await;
4701×
            }
4711×
            let signer = match signer_for(&app, me.account.id).await {
4721×
                Ok(s) => s,
4730×
                Err(msg) => return again(msg).await,
474
            };
475
            // Writing the kept version at the path settles the conflict.
4761×
            let edit = vec![FileEdit { path: path.clone(), content: Some(keep.content.clone()) }];
4771×
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
4781×
                Ok(d) => d,
4790×
                Err(e) => return again(e.to_string()).await,
480
            };
4811×
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
4821×
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
4831×
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
4841×
            return Ok(Redirect::to(&url).into_response());
485
        }
4861×
        Target::Name(candidates) => {
4871×
            let again = |msg: String| {
4880×
                let (app, viewer, owner, name, bookmark, path, message) =
4890×
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
4900×
                async move {
4910×
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
4920×
                        .await
4930×
                        .map(IntoResponse::into_response)
4940×
                }
4950×
            };
4961×
            if stale {
4970×
                return again(format!("{bookmark} has moved since this page was loaded; the candidates below are as they are now.")).await;
4981×
            }
4991×
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
5000×
                return again("Choose the candidate to keep.".into()).await;
501
            };
5021×
            if message.is_empty() {
5030×
                return again("Describe the resolution.".into()).await;
5041×
            }
5051×
            let signer = match signer_for(&app, me.account.id).await {
5061×
                Ok(s) => s,
5070×
                Err(msg) => return again(msg).await,
508
            };
509
            // The kept candidate's content at the name, every candidate
510
            // gone: the snapshot pairs the kept one with the name as a
511
            // move, so it keeps its identity.
5122×
            let mut edit: Vec<FileEdit> = candidates.iter().map(|c| FileEdit { path: c.path.clone(), content: None }).collect();
5131×
            edit.push(FileEdit { path: path.clone(), content: Some(keep.content.clone()) });
5141×
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
5151×
                Ok(d) => d,
5160×
                Err(e) => return again(e.to_string()).await,
517
            };
5181×
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
5191×
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
5201×
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
5211×
            return Ok(Redirect::to(&url).into_response());
522
        }
523
    };
5244×
    if stale {
5251×
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
5263×
    }
5273×
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
5283×
    let mut choices = HashMap::new();
5296×
    for p in &pieces {
5306×
        if let Piece::Region(r) = p {
5313×
            let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) {
5322×
                Some("all") => Choice::All,
5332×
                Some("none") => Choice::None,
5342×
                Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()),
5350×
                Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) {
5360×
                    Some(i) => Choice::Side(i),
5370×
                    None => continue,
538
                },
5391×
                None => continue,
540
            };
5412×
            choices.insert(r.n, choice);
5423×
        }
543
    }
5443×
    let content = match assemble(&pieces, &choices) {
5452×
        Ok(c) => c,
5461×
        Err(msg) => return again(msg).await,
547
    };
5482×
    if message.is_empty() {
5490×
        return again("Describe the resolution.".into()).await;
5502×
    }
5512×
    let signer = match signer_for(&app, me.account.id).await {
5522×
        Ok(s) => s,
5530×
        Err(msg) => return again(msg).await,
554
    };
5552×
    let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }];
5562×
    let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
5572×
        Ok(d) => d,
5580×
        Err(e) => return again(e.to_string()).await,
559
    };
5602×
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
5612×
    let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
5622×
    share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
5632×
    Ok(Redirect::to(&url).into_response())
5646×
}
565
566
#[cfg(test)]
567
mod tests {
568
    use super::*;
569
570
    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";
571
572
    #[test]
5731×
    fn markers_parse_into_regions_and_sides() {
5741×
        let p = parse(MARKED).unwrap();
5751×
        assert_eq!(p.len(), 5);
5761×
        assert_eq!(p[0], Piece::Plain("a\n".into()));
5771×
        let Piece::Region(r1) = &p[1] else { panic!() };
5781×
        assert_eq!((r1.n, r1.sides.len()), (1, 2));
5791×
        assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n"));
5801×
        assert_eq!(r1.sides[1].text, "from-3\nmore-3\n");
5811×
        let Piece::Region(r2) = &p[3] else { panic!() };
5821×
        assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty());
5831×
        assert_eq!(r2.sides[0].label, "deleted by change 0404");
5841×
        assert_eq!(p[4], Piece::Plain("c\n".into()));
5851×
        assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none());
5861×
        assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]);
5871×
    }
588
589
    #[test]
5901×
    fn choices_assemble_the_file() {
5911×
        let p = parse(MARKED).unwrap();
5924×
        let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)]));
5931×
        assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing");
5941×
        assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n");
5951×
        assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line");
5961×
        assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3"));
5971×
        assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2"));
5981×
    }
599
}
600
601
// -------------------------------------------------------------- live (P1b)
602
6036×
fn room(row: &crate::db::repos::RepoRow, bookmark: &str, path: &str) -> crate::live::RoomKey {
6046×
    crate::live::RoomKey { repo_id: row.id, bookmark: bookmark.to_string(), path: path.to_string() }
6056×
}
606
607
/// Apply a viewer's event to the room in this process and send it to the
608
/// same room in the others (docs/roles.md); `false` when nobody here is in
609
/// the room. Presence stays each process's own.
6105×
fn share(app: &Shared, key: crate::live::RoomKey, event: crate::live::RoomEvent) -> bool {
6115×
    let here = app.rooms.apply(&key, &event);
6125×
    app.bus.publish(crate::bus::Event::Room { room: key, event });
6135×
    here
6145×
}
615
616
#[derive(serde::Deserialize)]
617
pub struct LiveQuery {
618
    /// This browser tab's id, so a tab ignores its own choices.
619
    client: String,
620
}
621
622
/// `GET /{owner}/{name}/resolve-live/{bookmark}/{path}?client=…`: server-sent
623
/// events for everyone resolving this conflict (hub spec §6.10): a snapshot
624
/// of who is here and what they chose, then presence, choices, and the
625
/// resolution when it lands. Writers only, as the resolver.
6261×
pub async fn live_events(
6271×
    State(app): State<Shared>,
6281×
    viewer: Viewer,
6291×
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
6301×
    axum::extract::Query(q): axum::extract::Query<LiveQuery>,
6311×
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
632
    use futures::StreamExt;
6331×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
6341×
    let me = require(&viewer, &shell)?;
6351×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
6365×
    let client: String = q.client.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(64).collect();
6371×
    if client.is_empty() {
6380×
        return Err(Error::bad(shell, "A live view needs a client id."));
6391×
    }
6401×
    let key = room(&row, &bookmark, &path);
6411×
    let (rx, snapshot) = app.rooms.join(key.clone(), &client, &me.account.username);
6421×
    let seat = app.rooms.seat(key, &client);
6434×
    let event = |e: &crate::live::LiveEvent| {
6444×
        let kind = match e {
6451×
            crate::live::LiveEvent::Snapshot { .. } => "snapshot",
6461×
            crate::live::LiveEvent::Presence { .. } => "presence",
6471×
            crate::live::LiveEvent::Choice { .. } => "choice",
6481×
            crate::live::LiveEvent::Landed { .. } => "landed",
649
        };
6504×
        Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(e).unwrap_or_default()))
6514×
    };
6521×
    let first = futures::stream::once(std::future::ready(event(&snapshot)));
6533×
    let rest = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| {
654
        // The seat lives as long as the stream: dropping it leaves the room.
6553×
        let _held = &seat;
6563×
        std::future::ready(item.ok().map(|e| event(&e)))
6573×
    });
6581×
    Ok(axum::response::Sse::new(futures::StreamExt::take_until(first.chain(rest), crate::shutdown::stopping()))
6591×
        .keep_alive(axum::response::sse::KeepAlive::default()))
6601×
}
661
662
#[derive(serde::Deserialize)]
663
pub struct LiveChoice {
664
    #[serde(rename = "_csrf")]
665
    csrf_token: String,
666
    client: String,
667
    /// The region's number, or `keep` on a candidate page.
668
    region: String,
669
    choice: String,
670
    #[serde(default)]
671
    text: String,
672
}
673
674
/// `POST /{owner}/{name}/resolve-live/{bookmark}/{path}`: share a choice
675
/// with the others resolving this conflict. The last choice for a region
676
/// wins; nothing is committed until someone commits the resolution.
6772×
pub async fn live_choose(
6782×
    State(app): State<Shared>,
6792×
    viewer: Viewer,
6802×
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
6812×
    axum::Json(c): axum::Json<LiveChoice>,
6822×
) -> Result<axum::http::StatusCode, Error> {
6832×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
6842×
    let me = require(&viewer, &shell)?;
6852×
    csrf(&viewer, &c.csrf_token, &shell)?;
6862×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
6872×
    let valid_region = c.region == "keep" || c.region.parse::<usize>().is_ok();
6882×
    let valid_choice = matches!(c.choice.as_str(), "all" | "none" | "text")
6892×
        || c.choice.strip_prefix("side").is_some_and(|i| i.parse::<usize>().is_ok())
6901×
        || (c.region == "keep" && c.choice.parse::<usize>().is_ok());
6912×
    if !valid_region || !valid_choice || c.text.len() > 1 << 20 {
6921×
        return Err(Error::bad(shell, "That is not a choice."));
6931×
    }
6941×
    let choice = crate::live::Choice { choice: c.choice, text: c.text, by: me.account.username.clone() };
6951×
    Ok(if share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Choice { region: c.region, choice, client: c.client }) {
6961×
        axum::http::StatusCode::NO_CONTENT
697
    } else {
6980×
        axum::http::StatusCode::GONE
699
    })
7002×
}