OXYGENHUB

Coverage / crates/hub/src/web/history.rs 97.0%

359 of 370 lines run, 55 of 122 functions · run #504 on main · run missed

1
//! History, change and compare pages.
2
3
use super::{filters, lookup, Error, Page, RepoShell, Shell};
4
use crate::auth::Viewer;
5
use crate::render::{self, diff, graph};
6
use crate::repos::view;
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Path, Query, State};
10
use axum::response::{IntoResponse, Response};
11
use oxy_core::repo::FileDiff;
12
use serde::Deserialize;
13
use std::collections::BTreeMap;
14
15
const PAGE: usize = 50;
16
17
#[derive(Deserialize)]
18
pub struct HistoryQuery {
19
    path: Option<String>,
20
    offset: Option<usize>,
21
}
22
23
#[derive(Template)]
24
#[template(path = "history.html")]
25
pub struct HistoryPage {
26
    shell: Shell,
27
    repo: RepoShell,
28
    h: view::History,
29
    rows: Vec<graph::Row>,
30
    lanes: usize,
31
}
32
3311×
pub async fn history(
3411×
    State(app): State<Shared>,
3511×
    viewer: Viewer,
3611×
    Path((owner, name, r)): Path<(String, String, String)>,
3711×
    Query(q): Query<HistoryQuery>,
3811×
) -> Result<Page<HistoryPage>, Error> {
3911×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer);
4011×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4110×
    let spec = r.clone();
4210×
    let path = q.path.clone().filter(|p| !p.is_empty());
4310×
    let offset = q.offset.unwrap_or(0);
4410×
    let h = app
4510×
        .repos
4610×
        .read(&repo, move |repo, cache| {
4710×
            let target = view::resolve(repo, cache, &spec)?;
4810×
            view::history(repo, cache, target, path.as_deref(), offset, PAGE)
4910×
        })
5010×
        .await
5110×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
5210×
    let mut h = h;
5310×
    super::attribute(&app, &mut h.entries.iter_mut().collect::<Vec<_>>()).await;
5438×
    let graph_input: Vec<(String, Vec<String>)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
5510×
    let rows = graph::layout(&graph_input);
5610×
    let lanes = graph::width(&rows);
5710×
    Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes }))
5811×
}
59
60
#[derive(Deserialize)]
61
pub struct FunctionQuery {
62
    start: usize,
63
    end: usize,
64
    #[serde(default)]
65
    name: String,
66
}
67
68
#[derive(Template)]
69
#[template(path = "function.html")]
70
pub struct FunctionPage {
71
    shell: Shell,
72
    repo: RepoShell,
73
    r: String,
74
    path: String,
75
    name: String,
76
    start: usize,
77
    end: usize,
78
    entries: Vec<view::ChangeView>,
79
    rows: Vec<graph::Row>,
80
    lanes: usize,
81
}
82
83
/// `GET /{owner}/{name}/function/{ref}/{path}?start=…&end=…&name=…`: the
84
/// history of a function (hub spec §6.6): every change that wrote or
85
/// deleted one of its lines, exactly (Oxygen spec §27), with a graph of how
86
/// those changes depend on each other.
872×
pub async fn function(
882×
    State(app): State<Shared>,
892×
    viewer: Viewer,
902×
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
912×
    Query(q): Query<FunctionQuery>,
922×
) -> Result<Page<FunctionPage>, Error> {
932×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/function/{r}/{path}"), &viewer);
942×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
952×
    let (spec, p, start, end) = (r.clone(), path.clone(), q.start, q.end);
962×
    let mut entries = app
972×
        .repos
982×
        .read(&repo, move |repo, cache| {
992×
            let target = view::resolve(repo, cache, &spec)?;
1002×
            let history = repo.line_history(&target.heads, &p, start, end)?;
1011×
            let labels = view::head_labels(repo)?;
1022×
            Ok(history.iter().map(|e| view::change_view_pub(repo, &e.hash, &e.change, e.is_head, &labels)).collect::<Vec<_>>())
1032×
        })
1042×
        .await
1052×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
106
    // The graph shows how these changes depend on one another.
1072×
    let listed: std::collections::HashSet<String> = entries.iter().map(|c| c.hash.clone()).collect();
1082×
    for c in &mut entries {
1092×
        c.deps.retain(|d| listed.contains(d));
110
    }
1111×
    super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
1122×
    let graph_input: Vec<(String, Vec<String>)> = entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
1131×
    let rows = graph::layout(&graph_input);
1141×
    let lanes = graph::width(&rows);
1151×
    Ok(Page(FunctionPage {
1161×
        shell,
1171×
        repo: RepoShell::new(&app, &row, "code").with_role(role),
1181×
        r,
1191×
        path,
1201×
        name: q.name,
1211×
        start,
1221×
        end,
1231×
        entries,
1241×
        rows,
1251×
        lanes,
1261×
    }))
1272×
}
128
129
/// Changes a swimlane board shows at most.
130
const BOARD: usize = 60;
131
132
#[derive(Template)]
133
#[template(path = "lanes.html")]
134
pub struct LanesPage {
135
    shell: Shell,
136
    repo: RepoShell,
137
    target: view::RefTarget,
138
    total: usize,
139
    board: graph::Board,
140
    /// The open proposal whose source this bookmark is, when the viewer may
141
    /// rewrite it: cards can then be dragged onto each other (P3b).
142
    proposal: Option<i64>,
143
}
144
145
/// `GET /{owner}/{name}/lanes/{ref}`: a bookmark's newest changes as
146
/// swimlanes (hub spec §6.7): independent work in separate lanes, time
147
/// left to right, dependencies as edges and no merge nodes.
1483×
pub async fn lanes(
1493×
    State(app): State<Shared>,
1503×
    viewer: Viewer,
1513×
    Path((owner, name, r)): Path<(String, String, String)>,
1523×
) -> Result<Page<LanesPage>, Error> {
1533×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
1543×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1553×
    let spec = r.clone();
1563×
    let h = app
1573×
        .repos
1583×
        .read(&repo, move |repo, cache| {
1593×
            let target = view::resolve(repo, cache, &spec)?;
1603×
            view::history(repo, cache, target, None, 0, BOARD)
1613×
        })
1623×
        .await
1633×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1643×
    let board = graph::board(&h.entries);
1653×
    let proposal = editable_proposal(&app, &viewer, &row, role, &r).await.map(|p| p.number);
1663×
    Ok(Page(LanesPage {
1673×
        shell,
1683×
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
1693×
        target: h.target,
1703×
        total: h.total,
1713×
        board,
1723×
        proposal,
1733×
    }))
1743×
}
175
176
/// The open proposal whose source is `bookmark` of this repository, when
177
/// the viewer is its author or may write here: only those bookmarks are
178
/// rewritten from the board, and only by their owners' clients.
1797×
async fn editable_proposal(
1807×
    app: &Shared,
1817×
    viewer: &Viewer,
1827×
    row: &crate::db::repos::RepoRow,
1837×
    role: crate::auth::Role,
1847×
    bookmark: &str,
1857×
) -> Option<crate::db::proposals::ProposalRow> {
1867×
    let me = viewer.account()?;
1876×
    let open = crate::db::proposals::list(&app.db, row.id, &["open", "draft"]).await.ok()?;
1886×
    open.into_iter().find(|p| {
1895×
        p.source_bookmark == bookmark
1905×
            && p.source_repo_id.is_none_or(|id| id == row.id)
1915×
            && (p.author_id == Some(me.id) || role >= crate::auth::Role::Write)
1925×
    })
1937×
}
194
195
/// One rewrite the board offers for a drop (hub spec §6.7).
196
#[derive(serde::Serialize)]
197
pub struct PlanOption {
198
    /// `squash` or `rebase`.
199
    kind: &'static str,
200
    summary: String,
201
    /// What to run in a checkout of the bookmark.
202
    command: String,
203
    /// The same, handed to oxygen-vscode.
204
    vscode: String,
205
}
206
207
#[derive(Deserialize)]
208
pub struct PlanForm {
209
    #[serde(rename = "_csrf")]
210
    csrf_token: String,
211
    /// The dragged change and the one it was dropped on, full hashes.
212
    change: String,
213
    target: String,
214
}
215
216
/// The rewrites a drop may mean, for a proposal bookmark (pure, tested):
217
/// squashing `c` into `t` when `c` needs nothing `t` lacks, rebasing `c`
218
/// onto `t` when `t` does not already have it. Reordering independent
219
/// changes means nothing in Oxygen, so a drop never offers it.
2204×
pub fn plan_options(state: &oxy_core::state::State, c: &oxy_core::hash::ChangeHash, t: &oxy_core::hash::ChangeHash) -> Vec<&'static str> {
221
    use oxy_core::graph::DepOracle;
2224×
    if c == t || !state.contains(c) || !state.contains(t) || state.depends_on(t, c) {
2231×
        return vec![];
2243×
    }
2253×
    let mut out = Vec::new();
2263×
    let closure = state.closure(t);
2274×
    let deps_ok = state.get(c).is_some_and(|m| m.deps.iter().all(|d| d == t || closure.contains(d)));
2283×
    if deps_ok {
2292×
        out.push("squash");
2302×
    }
2313×
    if !state.get(c).is_some_and(|m| m.deps.contains(t)) {
2321×
        out.push("rebase");
2332×
    }
2343×
    out
2354×
}
236
237
/// `POST /{owner}/{name}/lanes/{bookmark}/plan`: what dropping one card on
238
/// another would do, as commands for the author's client to run and sign.
239
/// The hub rewrites nothing itself.
2404×
pub async fn plan(
2414×
    State(app): State<Shared>,
2424×
    viewer: Viewer,
2434×
    Path((owner, name, r)): Path<(String, String, String)>,
2444×
    axum::extract::Form(f): axum::extract::Form<PlanForm>,
2454×
) -> Result<Response, Error> {
2464×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
2474×
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
2484×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2494×
    let problem = |msg: &str| (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": msg }))).into_response();
2504×
    let Some(proposal) = editable_proposal(&app, &viewer, &row, role, &r).await else {
2510×
        return Ok(problem("Changes are rearranged only on the bookmark of an open proposal you may change."));
252
    };
2534×
    let (Some(c), Some(t)) = (oxy_core::hash::ChangeHash::from_hex(&f.change), oxy_core::hash::ChangeHash::from_hex(&f.target)) else {
2540×
        return Ok(problem("Name two changes."));
255
    };
2564×
    let (spec, target_bookmark) = (r.clone(), proposal.target.clone());
2574×
    let found = app
2584×
        .repos
2594×
        .read(&repo, move |repo, cache| {
2604×
            let here = view::resolve(repo, cache, &spec)?;
2614×
            let loaded = cache.load(repo, &here.heads)?;
262
            // What has landed on the target is history, not a draft.
2634×
            let landed = match repo.view()?.bookmarks.get(&target_bookmark) {
2644×
                Some(st) => {
2654×
                    let heads = repo.store().get_state(st)?.heads;
2664×
                    let target_state = cache.load(repo, &heads)?;
2674×
                    target_state.state.contains(&c) || target_state.state.contains(&t)
268
                }
2690×
                None => false,
270
            };
2718×
            let meta = |h: &oxy_core::hash::ChangeHash| loaded.state.get(h).map(|m| m.change_id.short(8));
2724×
            Ok((plan_options(&loaded.state, &c, &t), landed, meta(&c), meta(&t)))
2734×
        })
2744×
        .await
2754×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
2764×
    let (kinds, landed, Some(cid), Some(tid)) = found else {
2770×
        return Ok(problem("Both changes must be on this bookmark."));
278
    };
2794×
    if landed {
2801×
        return Ok(problem(&format!("That change is already on {}; only the proposal's own changes are rearranged.", proposal.target)));
2813×
    }
2823×
    if kinds.is_empty() {
2831×
        return Ok(problem("That drop means nothing here: the change is already part of the other, or they are the same."));
2842×
    }
2852×
    let base = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
2862×
    let enc = super::settings::urlencode;
2872×
    let options: Vec<PlanOption> = kinds
2882×
        .into_iter()
2893×
        .map(|kind| {
2903×
            let (summary, command) = match kind {
2913×
                "squash" => (
2922×
                    format!("Squash {cid} into {tid}: one change instead of two."),
2932×
                    format!("oxy squash #{} --into #{}", c.to_hex(), t.to_hex()),
2942×
                ),
2951×
                _ => (
2961×
                    format!("Rebase {cid} onto {tid}: {cid} comes to depend on {tid}."),
2971×
                    format!("oxy rebase #{} --onto #{}", c.to_hex(), t.to_hex()),
2981×
                ),
299
            };
3003×
            let vscode = format!(
301
                "vscode://oxygenhub.oxygen-vscode/plan?url={}&bookmark={}&kind={kind}&change={}&target={}",
3023×
                enc(&base),
3033×
                enc(&r),
3043×
                c.to_hex(),
3053×
                t.to_hex()
306
            );
3073×
            PlanOption { kind, summary, command: format!("oxy bookmark switch {r} && {command} && oxy push --force"), vscode }
3083×
        })
3092×
        .collect();
3102×
    Ok(axum::Json(serde_json::json!({ "proposal": proposal.number, "options": options })).into_response())
3114×
}
312
313
/// A file's diff, rendered.
314
pub struct FileRows {
315
    pub path: String,
316
    pub from: Option<String>,
317
    pub kind: String,
318
    pub lines: Vec<diff::Line>,
319
    pub added: usize,
320
    pub removed: usize,
321
    pub anchor: String,
322
}
323
324110×
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
325110×
    render_files_with(files, bases, diff::Options::default())
326110×
}
327
328
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
329128×
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
330128×
    files
331128×
        .iter()
332128×
        .enumerate()
333128×
        .map(|(i, f)| {
334127×
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
335127×
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
336
            FileRows {
337127×
                path: f.path.clone(),
338127×
                from: f.from.clone(),
339127×
                kind: f.kind.clone(),
340316×
                added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(),
341316×
                removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(),
342127×
                lines,
343127×
                anchor: format!("diff-{i}"),
344
            }
345127×
        })
346128×
        .collect()
347128×
}
348
349
#[derive(Template)]
350
#[template(path = "change.html")]
351
pub struct ChangePage {
352
    shell: Shell,
353
    repo: RepoShell,
354
    c: view::ChangeDetail,
355
    files: Vec<FileRows>,
356
    body_html: String,
357
    ignore_ws: bool,
358
    /// Receipts runners signed for the state whose only head is this change.
359
    receipts: Vec<super::flow::ReceiptRow>,
360
    /// Whether the viewer may run the pipeline on this change.
361
    can_run: bool,
362
}
363
364
/// `?w=1` on a diff page hides whitespace-only changes.
365
#[derive(Deserialize, Default)]
366
pub struct DiffQuery {
367
    #[serde(default)]
368
    w: Option<String>,
369
}
370
371
impl DiffQuery {
37236×
    pub fn options(&self) -> diff::Options {
37336×
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
37436×
    }
375
}
376
37713×
pub async fn change(
37813×
    State(app): State<Shared>,
37913×
    viewer: Viewer,
38013×
    Path((owner, name, hash)): Path<(String, String, String)>,
38113×
    Query(q): Query<DiffQuery>,
38213×
) -> Result<Page<ChangePage>, Error> {
38313×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
38413×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
38513×
    let spec = hash.clone();
38613×
    let c = app
38713×
        .repos
38813×
        .read(&repo, move |repo, cache| {
38913×
            let h = view::resolve_change(repo, cache, &spec)?;
39013×
            view::change(repo, cache, &h)
39113×
        })
39213×
        .await
39313×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
39413×
    let mut c = c;
395
    {
39613×
        let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view];
39713×
        all.extend(c.deps.iter_mut());
39813×
        all.extend(c.dependents.iter_mut());
39913×
        super::attribute(&app, &mut all).await;
400
    }
40113×
    let files = render_files_with(&c.files, &c.bases, q.options());
40213×
    let ignore_ws = q.options().ignore_whitespace;
40313×
    let body_html =
40413×
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
40513×
    let receipts = match oxy_core::hash::ChangeHash::from_hex(&c.view.hash) {
40613×
        Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await,
4070×
        None => vec![],
408
    };
40913×
    let can_run = super::flow::why_not_run(crate::flow::enabled(&app, &row), viewer.account().is_some(), role).is_none();
41013×
    Ok(Page(ChangePage {
41113×
        shell,
41213×
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
41313×
        c,
41413×
        files,
41513×
        body_html,
41613×
        ignore_ws,
41713×
        receipts,
41813×
        can_run,
41913×
    }))
42013×
}
421
422
#[derive(Template)]
423
#[template(path = "compare.html")]
424
pub struct ComparePage {
425
    shell: Shell,
426
    repo: RepoShell,
427
    cmp: view::Compare,
428
    files: Vec<FileRows>,
429
    ignore_ws: bool,
430
    /// The two sides as given, for the pickers.
431
    base_spec: String,
432
    head_spec: String,
433
    /// Bookmarks and tags to pick from.
434
    bookmarks: Vec<String>,
435
    tags: Vec<String>,
436
    propose: Option<Propose>,
437
}
438
439
/// "Propose this": head into base, when both are bookmarks and the viewer may write.
440
pub struct Propose {
441
    pub url: String,
442
    /// An open proposal of head into base already.
443
    pub existing: Option<i64>,
444
}
445
446
#[derive(Deserialize)]
447
pub struct CompareForm {
448
    base: Option<String>,
449
    head: Option<String>,
450
}
451
452
/// `/compare`: the pickers' form target, or a sensible first comparison:
453
/// the most recently changed other bookmark against the default.
4542×
pub async fn compare_index(
4552×
    State(app): State<Shared>,
4562×
    viewer: Viewer,
4572×
    Path((owner, name)): Path<(String, String)>,
4582×
    Query(q): Query<CompareForm>,
4592×
) -> Result<Response, Error> {
4602×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer);
4612×
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4624×
    let clean = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("..."));
4632×
    let (base, head) = match (clean(q.base), clean(q.head)) {
4641×
        (Some(b), Some(h)) => (b, h),
4651×
        (b, h) => {
4661×
            let default = row.default_bookmark.clone();
4671×
            let d = default.clone();
4681×
            let newest = app
4691×
                .repos
4701×
                .read(&repo, move |r, cache| {
4712×
                    Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name))
4721×
                })
4731×
                .await
4741×
                .ok()
4751×
                .flatten();
4761×
            (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default)))
477
        }
478
    };
4794×
    let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23");
4802×
    Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response())
4812×
}
482
483
/// `/compare/{base}...{head}`.
4846×
pub async fn compare(
4856×
    State(app): State<Shared>,
4866×
    viewer: Viewer,
4876×
    Path((owner, name, spec)): Path<(String, String, String)>,
4886×
    Query(q): Query<DiffQuery>,
4896×
) -> Result<Page<ComparePage>, Error> {
4906×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
4916×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4926×
    let Some((a, b)) = spec.split_once("...") else {
4931×
        return Err(Error::bad(shell, "compare takes base...head"));
494
    };
4955×
    let (a, b) = (a.to_string(), b.to_string());
4965×
    let cmp = app
4975×
        .repos
4985×
        .read(&repo, move |repo, cache| {
4995×
            let base = view::resolve(repo, cache, &a)?;
5005×
            let head = view::resolve(repo, cache, &b)?;
5015×
            view::compare(repo, cache, base, head)
5025×
        })
5035×
        .await
5045×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
5055×
    let mut cmp = cmp;
5065×
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
5075×
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
5085×
    let ignore_ws = q.options().ignore_whitespace;
5095×
    let rs = RepoShell::new(&app, &row, "changes").with_role(role);
5105×
    let (bookmarks, tags) = app
5115×
        .repos
5125×
        .read(&repo, |r, _| {
5135×
            let b: Vec<String> = r.bookmarks()?.into_iter().map(|b| b.name).collect();
5145×
            let t: Vec<String> = view::tags(r)?.into_iter().map(|t| t.name).collect();
5155×
            Ok((b, t))
5165×
        })
5175×
        .await
5185×
        .unwrap_or_default();
5195×
    let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark;
5205×
    let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) {
5212×
        let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default();
5222×
        let existing = open
5232×
            .iter()
5242×
            .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label)
5252×
            .map(|p| p.number);
5262×
        Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing })
527
    } else {
5283×
        None
529
    };
5305×
    let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default();
5315×
    Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose }))
5326×
}