OXYGENHUB
# ab2d8b40f9f4 oxygenhub / crates / hub / src / web / resolve.rs
30.0 KiB · Rust · last changed Carry events between the hub's processes on an event bus 16 days ago source blame history raw
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).
22
pub fn vscode_link(app: &Shared, owner: &str, name: &str, bookmark: &str, path: &str) -> String {
23
    use super::settings::urlencode;
24
    let url = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
25
    format!("vscode://oxygenhub.oxygen-vscode/resolve?url={}&bookmark={}&path={}", urlencode(&url), urlencode(bookmark), urlencode(path))
26
}
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.
56
    pub fn kept(&self) -> &str {
57
        if self.deletion {
58
            ""
59
        } else {
60
            &self.text
61
        }
62
    }
63
}
64
65
impl Region {
66
    /// Every side in order: what keeping all of them puts in the file.
67
    pub fn all(&self) -> String {
68
        self.sides.iter().map(Side::kept).collect()
69
    }
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).
74
pub fn parse(marked: &str) -> Option<Vec<Piece>> {
75
    let mut out = Vec::new();
76
    let mut plain = String::new();
77
    let mut region: Option<Region> = None;
78
    let mut lines = marked.split_inclusive('\n').peekable();
79
    while let Some(line) = lines.next() {
80
        let bare = line.trim_end_matches('\n');
81
        if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") {
82
            if region.is_some() {
83
                return None;
84
            }
85
            if !plain.is_empty() {
86
                out.push(Piece::Plain(std::mem::take(&mut plain)));
87
            }
88
            let (n, label) = header(rest)?;
89
            let mut side = Side { label, text: String::new(), deletion: false };
90
            if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") {
91
                lines.next();
92
                side.deletion = true;
93
            }
94
            region = Some(Region { n, sides: vec![side] });
95
        } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") {
96
            let r = region.as_mut()?;
97
            let (n, label) = header(rest)?;
98
            if n != r.n {
99
                return None;
100
            }
101
            r.sides.push(Side { label, text: String::new(), deletion: false });
102
        } else if bare.starts_with(">>>>>>> oxy conflict ") {
103
            out.push(Piece::Region(region.take()?));
104
        } else {
105
            match region.as_mut() {
106
                Some(r) => r.sides.last_mut()?.text.push_str(line),
107
                None => plain.push_str(line),
108
            }
109
        }
110
    }
111
    if region.is_some() {
112
        return None;
113
    }
114
    if !plain.is_empty() {
115
        out.push(Piece::Plain(plain));
116
    }
117
    Some(out)
118
}
119
120
/// `N of T, side I of K: LABEL` → (N, LABEL).
121
fn header(rest: &str) -> Option<(usize, String)> {
122
    let (n, tail) = rest.split_once(" of ")?;
123
    let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default();
124
    Some((n.trim().parse().ok()?, label))
125
}
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.
138
pub fn assemble(pieces: &[Piece], choices: &HashMap<usize, Choice>) -> Result<String, String> {
139
    let mut out = String::new();
140
    for p in pieces {
141
        match p {
142
            Piece::Plain(t) => out.push_str(t),
143
            Piece::Region(r) => match choices.get(&r.n) {
144
                Some(Choice::Side(i)) => {
145
                    out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept())
146
                }
147
                Some(Choice::All) => out.push_str(&r.all()),
148
                Some(Choice::None) => {}
149
                Some(Choice::Text(t)) => {
150
                    let t = t.replace("\r\n", "\n");
151
                    out.push_str(&t);
152
                    if !t.is_empty() && !t.ends_with('\n') {
153
                        out.push('\n');
154
                    }
155
                }
156
                None => return Err(format!("choose how to resolve conflict {}", r.n)),
157
            },
158
        }
159
    }
160
    Ok(out)
161
}
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.
175
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;
179
    let by_node = snapshot.tree.by_node();
180
    for c in &snapshot.tree.conflicts {
181
        let TreeConflict::Name { parent, name, nodes } = c else { continue };
182
        let full = if parent.is_root() {
183
            name.clone()
184
        } else {
185
            match by_node.get(parent) {
186
                Some(e) => format!("{}/{name}", e.path),
187
                None => continue,
188
            }
189
        };
190
        if full != path {
191
            continue;
192
        }
193
        let mut out = Vec::new();
194
        for n in nodes {
195
            let Some(e) = by_node.get(n) else { continue };
196
            let f = snapshot.files.get(&e.path).ok_or("a candidate is missing")?;
197
            if f.kind != NodeKind::File {
198
                return Err("This conflict is between directories or links; resolve it from a client.".into());
199
            }
200
            let content = match &f.content {
201
                FileContent::Text { bytes, .. } => bytes.clone(),
202
                FileContent::Opaque { blobs } => match blobs.first() {
203
                    Some(b) => r.read_blob(b).map_err(|e| e.to_string())?,
204
                    None => vec![],
205
                },
206
                FileContent::Dir => continue,
207
            };
208
            let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
209
            out.push(Candidate { path: e.path.clone(), content, preview });
210
        }
211
        out.sort_by(|a, b| a.path.cmp(&b.path));
212
        return Ok(Some(out));
213
    }
214
    Ok(None)
215
}
216
217
/// The versions of a binary file with a replace conflict at `path`, each
218
/// labelled with the change that wrote it.
219
fn replace_candidates(
220
    r: &oxy_core::repo::Repo,
221
    state: &oxy_core::state::State,
222
    snapshot: &oxy_core::state::Snapshot,
223
    path: &str,
224
) -> Option<Vec<Candidate>> {
225
    use oxy_core::state::FileContent;
226
    let f = snapshot.files.get(path)?;
227
    let FileContent::Opaque { blobs } = &f.content else { return None };
228
    if blobs.len() < 2 || f.kind != oxy_core::objects::NodeKind::File {
229
        return None;
230
    }
231
    let writers = state.live_replaces(&f.node);
232
    let mut out = Vec::new();
233
    for (i, b) in blobs.iter().enumerate() {
234
        let content = r.read_blob(b).ok()?;
235
        let by = writers.iter().find(|(_, n)| n == b).map(|(h, _)| state.label(h)).unwrap_or_default();
236
        let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
237
        out.push(Candidate { path: format!("version {} from change {by}", i + 1), content, preview });
238
    }
239
    Some(out)
240
}
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.
287
async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, Target), Error> {
288
    let limit = app.config.limits.render_limit.0;
289
    let (b, p) = (bookmark.to_string(), path.to_string());
290
    let found = app
291
        .repos
292
        .read(repo, move |r, cache| {
293
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
294
            let heads = r.store().get_state(&st)?.heads;
295
            let loaded = cache.load(r, &heads)?;
296
            let names = name_candidates(r, &loaded.snapshot, &p);
297
            let versions = replace_candidates(r, &loaded.state, &loaded.snapshot, &p);
298
            let file = if names.as_ref().is_ok_and(|n| n.is_some()) {
299
                None
300
            } else {
301
                let target = view::resolve(r, cache, &b)?;
302
                Some(view::file(r, cache, target, &p, limit))
303
            };
304
            Ok(Some((st.to_hex(), names, file, versions)))
305
        })
306
        .await
307
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
308
    let Some((state, names, file, versions)) = found else {
309
        return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark."));
310
    };
311
    if let Some(v) = versions {
312
        return Ok((state, Target::Replace(v)));
313
    }
314
    match names {
315
        Ok(Some(c)) => return Ok((state, Target::Name(c))),
316
        Err(msg) => return Err(Error::bad(shell.clone(), &msg)),
317
        Ok(None) => {}
318
    }
319
    let file = match file {
320
        Some(Ok(f)) => f,
321
        Some(Err(e)) => return Err(Error::from_repo(shell.clone(), e)),
322
        None => return Err(Error::bad(shell.clone(), "Nothing to resolve there.")),
323
    };
324
    match file.body {
325
        FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))),
326
        FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")),
327
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")),
328
    }
329
}
330
331
#[allow(clippy::too_many_arguments)]
332
async fn page(
333
    app: &Shared,
334
    viewer: &Viewer,
335
    owner: &str,
336
    name: &str,
337
    bookmark: &str,
338
    path: &str,
339
    error: Option<String>,
340
    message: Option<String>,
341
) -> Result<Page<ResolvePage>, Error> {
342
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
343
    let me = require(viewer, &shell)?;
344
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
345
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
346
    let text = match target {
347
        Target::Lines(t) => t,
348
        Target::Name(_) | Target::Replace(_) => return Err(Error::bad(shell, "That conflict is resolved by keeping a candidate.")),
349
    };
350
    let Some(pieces) = parse(&text) else {
351
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
352
    };
353
    let delegation = signer_for(app, me.account.id).await.err();
354
    Ok(Page(ResolvePage {
355
        shell,
356
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
357
        bookmark: bookmark.to_string(),
358
        path: path.to_string(),
359
        state,
360
        pieces,
361
        vscode: vscode_link(app, owner, name, bookmark, path),
362
        delegation,
363
        error,
364
        message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")),
365
    }))
366
}
367
368
pub async fn form(
369
    State(app): State<Shared>,
370
    viewer: Viewer,
371
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
372
) -> Result<Response, Error> {
373
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
374
    let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
375
    match load(&app, &shell, &repo, &bookmark, &path).await?.1 {
376
        // Malformed markers: the plain editor still resolves the file.
377
        Target::Lines(text) if parse(&text).is_none() => {
378
            Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response())
379
        }
380
        Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
381
        Target::Name(_) | Target::Replace(_) => {
382
            Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response())
383
        }
384
    }
385
}
386
387
#[allow(clippy::too_many_arguments)]
388
async fn name_page(
389
    app: &Shared,
390
    viewer: &Viewer,
391
    owner: &str,
392
    name: &str,
393
    bookmark: &str,
394
    path: &str,
395
    error: Option<String>,
396
    message: Option<String>,
397
) -> Result<Page<NamePage>, Error> {
398
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
399
    let me = require(viewer, &shell)?;
400
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
401
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
402
    let (candidates, versions) = match target {
403
        Target::Name(c) => (c, false),
404
        Target::Replace(c) => (c, true),
405
        Target::Lines(_) => return Err(Error::bad(shell, "That conflict is resolved region by region.")),
406
    };
407
    let delegation = signer_for(app, me.account.id).await.err();
408
    Ok(Page(NamePage {
409
        shell,
410
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
411
        bookmark: bookmark.to_string(),
412
        path: path.to_string(),
413
        state,
414
        candidates,
415
        versions,
416
        vscode: vscode_link(app, owner, name, bookmark, path),
417
        delegation,
418
        error,
419
        message: message.unwrap_or_else(|| {
420
            if versions {
421
                format!("Resolve the conflicting versions of {path}")
422
            } else {
423
                format!("Resolve the name conflict at {path}")
424
            }
425
        }),
426
    }))
427
}
428
429
pub async fn save(
430
    State(app): State<Shared>,
431
    viewer: Viewer,
432
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
433
    Form(f): Form<HashMap<String, String>>,
434
) -> Result<Response, Error> {
435
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
436
    let me = require(&viewer, &shell)?;
437
    csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?;
438
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
439
    let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default();
440
    let again = |msg: String| {
441
        let (app, viewer, owner, name, bookmark, path, message) =
442
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
443
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
444
    };
445
    let (state, target) = load(&app, &shell, &repo, &bookmark, &path).await?;
446
    let stale = f.get("state") != Some(&state);
447
    let text = match target {
448
        Target::Lines(t) => t,
449
        Target::Name(candidates) | Target::Replace(candidates) if candidates.is_empty() => {
450
            return again("There are no candidates to keep.".into()).await;
451
        }
452
        Target::Replace(candidates) => {
453
            let again = |msg: String| {
454
                let (app, viewer, owner, name, bookmark, path, message) =
455
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
456
                async move {
457
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
458
                        .await
459
                        .map(IntoResponse::into_response)
460
                }
461
            };
462
            if stale {
463
                return again(format!("{bookmark} has moved since this page was loaded; the versions below are as they are now.")).await;
464
            }
465
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
466
                return again("Choose the version to keep.".into()).await;
467
            };
468
            if message.is_empty() {
469
                return again("Describe the resolution.".into()).await;
470
            }
471
            let signer = match signer_for(&app, me.account.id).await {
472
                Ok(s) => s,
473
                Err(msg) => return again(msg).await,
474
            };
475
            // Writing the kept version at the path settles the conflict.
476
            let edit = vec![FileEdit { path: path.clone(), content: Some(keep.content.clone()) }];
477
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
478
                Ok(d) => d,
479
                Err(e) => return again(e.to_string()).await,
480
            };
481
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
482
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
483
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
484
            return Ok(Redirect::to(&url).into_response());
485
        }
486
        Target::Name(candidates) => {
487
            let again = |msg: String| {
488
                let (app, viewer, owner, name, bookmark, path, message) =
489
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
490
                async move {
491
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
492
                        .await
493
                        .map(IntoResponse::into_response)
494
                }
495
            };
496
            if stale {
497
                return again(format!("{bookmark} has moved since this page was loaded; the candidates below are as they are now.")).await;
498
            }
499
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
500
                return again("Choose the candidate to keep.".into()).await;
501
            };
502
            if message.is_empty() {
503
                return again("Describe the resolution.".into()).await;
504
            }
505
            let signer = match signer_for(&app, me.account.id).await {
506
                Ok(s) => s,
507
                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.
512
            let mut edit: Vec<FileEdit> = candidates.iter().map(|c| FileEdit { path: c.path.clone(), content: None }).collect();
513
            edit.push(FileEdit { path: path.clone(), content: Some(keep.content.clone()) });
514
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
515
                Ok(d) => d,
516
                Err(e) => return again(e.to_string()).await,
517
            };
518
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
519
            let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
520
            share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
521
            return Ok(Redirect::to(&url).into_response());
522
        }
523
    };
524
    if stale {
525
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
526
    }
527
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
528
    let mut choices = HashMap::new();
529
    for p in &pieces {
530
        if let Piece::Region(r) = p {
531
            let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) {
532
                Some("all") => Choice::All,
533
                Some("none") => Choice::None,
534
                Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()),
535
                Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) {
536
                    Some(i) => Choice::Side(i),
537
                    None => continue,
538
                },
539
                None => continue,
540
            };
541
            choices.insert(r.n, choice);
542
        }
543
    }
544
    let content = match assemble(&pieces, &choices) {
545
        Ok(c) => c,
546
        Err(msg) => return again(msg).await,
547
    };
548
    if message.is_empty() {
549
        return again("Describe the resolution.".into()).await;
550
    }
551
    let signer = match signer_for(&app, me.account.id).await {
552
        Ok(s) => s,
553
        Err(msg) => return again(msg).await,
554
    };
555
    let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }];
556
    let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
557
        Ok(d) => d,
558
        Err(e) => return again(e.to_string()).await,
559
    };
560
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
561
    let url = format!("/{owner}/{name}/change/{}", done.change.to_hex());
562
    share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Landed { url: url.clone(), by: me.account.username.clone() });
563
    Ok(Redirect::to(&url).into_response())
564
}
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]
573
    fn markers_parse_into_regions_and_sides() {
574
        let p = parse(MARKED).unwrap();
575
        assert_eq!(p.len(), 5);
576
        assert_eq!(p[0], Piece::Plain("a\n".into()));
577
        let Piece::Region(r1) = &p[1] else { panic!() };
578
        assert_eq!((r1.n, r1.sides.len()), (1, 2));
579
        assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n"));
580
        assert_eq!(r1.sides[1].text, "from-3\nmore-3\n");
581
        let Piece::Region(r2) = &p[3] else { panic!() };
582
        assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty());
583
        assert_eq!(r2.sides[0].label, "deleted by change 0404");
584
        assert_eq!(p[4], Piece::Plain("c\n".into()));
585
        assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none());
586
        assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]);
587
    }
588
589
    #[test]
590
    fn choices_assemble_the_file() {
591
        let p = parse(MARKED).unwrap();
592
        let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)]));
593
        assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing");
594
        assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n");
595
        assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line");
596
        assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3"));
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
/// 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.
610
fn share(app: &Shared, key: crate::live::RoomKey, event: crate::live::RoomEvent) -> bool {
611
    let here = app.rooms.apply(&key, &event);
612
    app.bus.publish(crate::bus::Event::Room { room: key, event });
613
    here
614
}
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.
626
pub async fn live_events(
627
    State(app): State<Shared>,
628
    viewer: Viewer,
629
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
630
    axum::extract::Query(q): axum::extract::Query<LiveQuery>,
631
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
632
    use futures::StreamExt;
633
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
634
    let me = require(&viewer, &shell)?;
635
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
636
    let client: String = q.client.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(64).collect();
637
    if client.is_empty() {
638
        return Err(Error::bad(shell, "A live view needs a client id."));
639
    }
640
    let key = room(&row, &bookmark, &path);
641
    let (rx, snapshot) = app.rooms.join(key.clone(), &client, &me.account.username);
642
    let seat = app.rooms.seat(key, &client);
643
    let event = |e: &crate::live::LiveEvent| {
644
        let kind = match e {
645
            crate::live::LiveEvent::Snapshot { .. } => "snapshot",
646
            crate::live::LiveEvent::Presence { .. } => "presence",
647
            crate::live::LiveEvent::Choice { .. } => "choice",
648
            crate::live::LiveEvent::Landed { .. } => "landed",
649
        };
650
        Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(e).unwrap_or_default()))
651
    };
652
    let first = futures::stream::once(std::future::ready(event(&snapshot)));
653
    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.
655
        let _held = &seat;
656
        std::future::ready(item.ok().map(|e| event(&e)))
657
    });
658
    Ok(axum::response::Sse::new(futures::StreamExt::take_until(first.chain(rest), crate::shutdown::stopping()))
659
        .keep_alive(axum::response::sse::KeepAlive::default()))
660
}
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.
677
pub async fn live_choose(
678
    State(app): State<Shared>,
679
    viewer: Viewer,
680
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
681
    axum::Json(c): axum::Json<LiveChoice>,
682
) -> Result<axum::http::StatusCode, Error> {
683
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
684
    let me = require(&viewer, &shell)?;
685
    csrf(&viewer, &c.csrf_token, &shell)?;
686
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
687
    let valid_region = c.region == "keep" || c.region.parse::<usize>().is_ok();
688
    let valid_choice = matches!(c.choice.as_str(), "all" | "none" | "text")
689
        || c.choice.strip_prefix("side").is_some_and(|i| i.parse::<usize>().is_ok())
690
        || (c.region == "keep" && c.choice.parse::<usize>().is_ok());
691
    if !valid_region || !valid_choice || c.text.len() > 1 << 20 {
692
        return Err(Error::bad(shell, "That is not a choice."));
693
    }
694
    let choice = crate::live::Choice { choice: c.choice, text: c.text, by: me.account.username.clone() };
695
    Ok(if share(&app, room(&row, &bookmark, &path), crate::live::RoomEvent::Choice { region: c.region, choice, client: c.client }) {
696
        axum::http::StatusCode::NO_CONTENT
697
    } else {
698
        axum::http::StatusCode::GONE
699
    })
700
}