OXYGENHUB
◆ v0.9.19 oxygenhub / crates / hub / src / web / history.rs
19.9 KiB · Rust · last changed VS Code links name oxygenhub.oxygen-vscode again 1 month ago source blame history raw
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
33
pub async fn history(
34
    State(app): State<Shared>,
35
    viewer: Viewer,
36
    Path((owner, name, r)): Path<(String, String, String)>,
37
    Query(q): Query<HistoryQuery>,
38
) -> Result<Page<HistoryPage>, Error> {
39
    let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer);
40
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
41
    let spec = r.clone();
42
    let path = q.path.clone().filter(|p| !p.is_empty());
43
    let offset = q.offset.unwrap_or(0);
44
    let h = app
45
        .repos
46
        .read(&repo, move |repo, cache| {
47
            let target = view::resolve(repo, cache, &spec)?;
48
            view::history(repo, cache, target, path.as_deref(), offset, PAGE)
49
        })
50
        .await
51
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
52
    let mut h = h;
53
    super::attribute(&app, &mut h.entries.iter_mut().collect::<Vec<_>>()).await;
54
    let graph_input: Vec<(String, Vec<String>)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
55
    let rows = graph::layout(&graph_input);
56
    let lanes = graph::width(&rows);
57
    Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes }))
58
}
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.
87
pub async fn function(
88
    State(app): State<Shared>,
89
    viewer: Viewer,
90
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
91
    Query(q): Query<FunctionQuery>,
92
) -> Result<Page<FunctionPage>, Error> {
93
    let shell = Shell::new(&app, &format!("/{owner}/{name}/function/{r}/{path}"), &viewer);
94
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
95
    let (spec, p, start, end) = (r.clone(), path.clone(), q.start, q.end);
96
    let mut entries = app
97
        .repos
98
        .read(&repo, move |repo, cache| {
99
            let target = view::resolve(repo, cache, &spec)?;
100
            let history = repo.line_history(&target.heads, &p, start, end)?;
101
            let labels = view::head_labels(repo)?;
102
            Ok(history.iter().map(|e| view::change_view_pub(repo, &e.hash, &e.change, e.is_head, &labels)).collect::<Vec<_>>())
103
        })
104
        .await
105
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
106
    // The graph shows how these changes depend on one another.
107
    let listed: std::collections::HashSet<String> = entries.iter().map(|c| c.hash.clone()).collect();
108
    for c in &mut entries {
109
        c.deps.retain(|d| listed.contains(d));
110
    }
111
    super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
112
    let graph_input: Vec<(String, Vec<String>)> = entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
113
    let rows = graph::layout(&graph_input);
114
    let lanes = graph::width(&rows);
115
    Ok(Page(FunctionPage {
116
        shell,
117
        repo: RepoShell::new(&app, &row, "code").with_role(role),
118
        r,
119
        path,
120
        name: q.name,
121
        start,
122
        end,
123
        entries,
124
        rows,
125
        lanes,
126
    }))
127
}
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.
148
pub async fn lanes(
149
    State(app): State<Shared>,
150
    viewer: Viewer,
151
    Path((owner, name, r)): Path<(String, String, String)>,
152
) -> Result<Page<LanesPage>, Error> {
153
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
154
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
155
    let spec = r.clone();
156
    let h = app
157
        .repos
158
        .read(&repo, move |repo, cache| {
159
            let target = view::resolve(repo, cache, &spec)?;
160
            view::history(repo, cache, target, None, 0, BOARD)
161
        })
162
        .await
163
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
164
    let board = graph::board(&h.entries);
165
    let proposal = editable_proposal(&app, &viewer, &row, role, &r).await.map(|p| p.number);
166
    Ok(Page(LanesPage {
167
        shell,
168
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
169
        target: h.target,
170
        total: h.total,
171
        board,
172
        proposal,
173
    }))
174
}
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.
179
async fn editable_proposal(
180
    app: &Shared,
181
    viewer: &Viewer,
182
    row: &crate::db::repos::RepoRow,
183
    role: crate::auth::Role,
184
    bookmark: &str,
185
) -> Option<crate::db::proposals::ProposalRow> {
186
    let me = viewer.account()?;
187
    let open = crate::db::proposals::list(&app.db, row.id, &["open", "draft"]).await.ok()?;
188
    open.into_iter().find(|p| {
189
        p.source_bookmark == bookmark
190
            && p.source_repo_id.is_none_or(|id| id == row.id)
191
            && (p.author_id == Some(me.id) || role >= crate::auth::Role::Write)
192
    })
193
}
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.
220
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;
222
    if c == t || !state.contains(c) || !state.contains(t) || state.depends_on(t, c) {
223
        return vec![];
224
    }
225
    let mut out = Vec::new();
226
    let closure = state.closure(t);
227
    let deps_ok = state.get(c).is_some_and(|m| m.deps.iter().all(|d| d == t || closure.contains(d)));
228
    if deps_ok {
229
        out.push("squash");
230
    }
231
    if !state.get(c).is_some_and(|m| m.deps.contains(t)) {
232
        out.push("rebase");
233
    }
234
    out
235
}
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.
240
pub async fn plan(
241
    State(app): State<Shared>,
242
    viewer: Viewer,
243
    Path((owner, name, r)): Path<(String, String, String)>,
244
    axum::extract::Form(f): axum::extract::Form<PlanForm>,
245
) -> Result<Response, Error> {
246
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
247
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
248
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
249
    let problem = |msg: &str| (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": msg }))).into_response();
250
    let Some(proposal) = editable_proposal(&app, &viewer, &row, role, &r).await else {
251
        return Ok(problem("Changes are rearranged only on the bookmark of an open proposal you may change."));
252
    };
253
    let (Some(c), Some(t)) = (oxy_core::hash::ChangeHash::from_hex(&f.change), oxy_core::hash::ChangeHash::from_hex(&f.target)) else {
254
        return Ok(problem("Name two changes."));
255
    };
256
    let (spec, target_bookmark) = (r.clone(), proposal.target.clone());
257
    let found = app
258
        .repos
259
        .read(&repo, move |repo, cache| {
260
            let here = view::resolve(repo, cache, &spec)?;
261
            let loaded = cache.load(repo, &here.heads)?;
262
            // What has landed on the target is history, not a draft.
263
            let landed = match repo.view()?.bookmarks.get(&target_bookmark) {
264
                Some(st) => {
265
                    let heads = repo.store().get_state(st)?.heads;
266
                    let target_state = cache.load(repo, &heads)?;
267
                    target_state.state.contains(&c) || target_state.state.contains(&t)
268
                }
269
                None => false,
270
            };
271
            let meta = |h: &oxy_core::hash::ChangeHash| loaded.state.get(h).map(|m| m.change_id.short(8));
272
            Ok((plan_options(&loaded.state, &c, &t), landed, meta(&c), meta(&t)))
273
        })
274
        .await
275
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
276
    let (kinds, landed, Some(cid), Some(tid)) = found else {
277
        return Ok(problem("Both changes must be on this bookmark."));
278
    };
279
    if landed {
280
        return Ok(problem(&format!("That change is already on {}; only the proposal's own changes are rearranged.", proposal.target)));
281
    }
282
    if kinds.is_empty() {
283
        return Ok(problem("That drop means nothing here: the change is already part of the other, or they are the same."));
284
    }
285
    let base = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
286
    let enc = super::settings::urlencode;
287
    let options: Vec<PlanOption> = kinds
288
        .into_iter()
289
        .map(|kind| {
290
            let (summary, command) = match kind {
291
                "squash" => (
292
                    format!("Squash {cid} into {tid}: one change instead of two."),
293
                    format!("oxy squash #{} --into #{}", c.to_hex(), t.to_hex()),
294
                ),
295
                _ => (
296
                    format!("Rebase {cid} onto {tid}: {cid} comes to depend on {tid}."),
297
                    format!("oxy rebase #{} --onto #{}", c.to_hex(), t.to_hex()),
298
                ),
299
            };
300
            let vscode = format!(
301
                "vscode://oxygenhub.oxygen-vscode/plan?url={}&bookmark={}&kind={kind}&change={}&target={}",
302
                enc(&base),
303
                enc(&r),
304
                c.to_hex(),
305
                t.to_hex()
306
            );
307
            PlanOption { kind, summary, command: format!("oxy bookmark switch {r} && {command} && oxy push --force"), vscode }
308
        })
309
        .collect();
310
    Ok(axum::Json(serde_json::json!({ "proposal": proposal.number, "options": options })).into_response())
311
}
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
324
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
325
    render_files_with(files, bases, diff::Options::default())
326
}
327
328
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
329
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
330
    files
331
        .iter()
332
        .enumerate()
333
        .map(|(i, f)| {
334
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
335
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
336
            FileRows {
337
                path: f.path.clone(),
338
                from: f.from.clone(),
339
                kind: f.kind.clone(),
340
                added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(),
341
                removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(),
342
                lines,
343
                anchor: format!("diff-{i}"),
344
            }
345
        })
346
        .collect()
347
}
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 {
372
    pub fn options(&self) -> diff::Options {
373
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
374
    }
375
}
376
377
pub async fn change(
378
    State(app): State<Shared>,
379
    viewer: Viewer,
380
    Path((owner, name, hash)): Path<(String, String, String)>,
381
    Query(q): Query<DiffQuery>,
382
) -> Result<Page<ChangePage>, Error> {
383
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
384
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
385
    let spec = hash.clone();
386
    let c = app
387
        .repos
388
        .read(&repo, move |repo, cache| {
389
            let h = view::resolve_change(repo, cache, &spec)?;
390
            view::change(repo, cache, &h)
391
        })
392
        .await
393
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
394
    let mut c = c;
395
    {
396
        let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view];
397
        all.extend(c.deps.iter_mut());
398
        all.extend(c.dependents.iter_mut());
399
        super::attribute(&app, &mut all).await;
400
    }
401
    let files = render_files_with(&c.files, &c.bases, q.options());
402
    let ignore_ws = q.options().ignore_whitespace;
403
    let body_html =
404
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
405
    let receipts = match oxy_core::hash::ChangeHash::from_hex(&c.view.hash) {
406
        Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await,
407
        None => vec![],
408
    };
409
    let can_run = super::flow::why_not_run(crate::flow::enabled(&app, &row), viewer.account().is_some(), role).is_none();
410
    Ok(Page(ChangePage {
411
        shell,
412
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
413
        c,
414
        files,
415
        body_html,
416
        ignore_ws,
417
        receipts,
418
        can_run,
419
    }))
420
}
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.
454
pub async fn compare_index(
455
    State(app): State<Shared>,
456
    viewer: Viewer,
457
    Path((owner, name)): Path<(String, String)>,
458
    Query(q): Query<CompareForm>,
459
) -> Result<Response, Error> {
460
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer);
461
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
462
    let clean = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("..."));
463
    let (base, head) = match (clean(q.base), clean(q.head)) {
464
        (Some(b), Some(h)) => (b, h),
465
        (b, h) => {
466
            let default = row.default_bookmark.clone();
467
            let d = default.clone();
468
            let newest = app
469
                .repos
470
                .read(&repo, move |r, cache| {
471
                    Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name))
472
                })
473
                .await
474
                .ok()
475
                .flatten();
476
            (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default)))
477
        }
478
    };
479
    let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23");
480
    Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response())
481
}
482
483
/// `/compare/{base}...{head}`.
484
pub async fn compare(
485
    State(app): State<Shared>,
486
    viewer: Viewer,
487
    Path((owner, name, spec)): Path<(String, String, String)>,
488
    Query(q): Query<DiffQuery>,
489
) -> Result<Page<ComparePage>, Error> {
490
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
491
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
492
    let Some((a, b)) = spec.split_once("...") else {
493
        return Err(Error::bad(shell, "compare takes base...head"));
494
    };
495
    let (a, b) = (a.to_string(), b.to_string());
496
    let cmp = app
497
        .repos
498
        .read(&repo, move |repo, cache| {
499
            let base = view::resolve(repo, cache, &a)?;
500
            let head = view::resolve(repo, cache, &b)?;
501
            view::compare(repo, cache, base, head)
502
        })
503
        .await
504
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
505
    let mut cmp = cmp;
506
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
507
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
508
    let ignore_ws = q.options().ignore_whitespace;
509
    let rs = RepoShell::new(&app, &row, "changes").with_role(role);
510
    let (bookmarks, tags) = app
511
        .repos
512
        .read(&repo, |r, _| {
513
            let b: Vec<String> = r.bookmarks()?.into_iter().map(|b| b.name).collect();
514
            let t: Vec<String> = view::tags(r)?.into_iter().map(|t| t.name).collect();
515
            Ok((b, t))
516
        })
517
        .await
518
        .unwrap_or_default();
519
    let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark;
520
    let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) {
521
        let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default();
522
        let existing = open
523
            .iter()
524
            .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label)
525
            .map(|p| p.number);
526
        Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing })
527
    } else {
528
        None
529
    };
530
    let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default();
531
    Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose }))
532
}