OXYGENHUB
# 045f967db8f2 oxygenhub / crates / hub / src / web / history.rs
6.2 KiB · Rust · last changed Diffs: word marks, whitespace, split view, collapsing 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 oxy_core::repo::FileDiff;
11
use serde::Deserialize;
12
use std::collections::BTreeMap;
13
14
const PAGE: usize = 50;
15
16
#[derive(Deserialize)]
17
pub struct HistoryQuery {
18
    path: Option<String>,
19
    offset: Option<usize>,
20
}
21
22
#[derive(Template)]
23
#[template(path = "history.html")]
24
pub struct HistoryPage {
25
    shell: Shell,
26
    repo: RepoShell,
27
    h: view::History,
28
    rows: Vec<graph::Row>,
29
    lanes: usize,
30
}
31
32
pub async fn history(
33
    State(app): State<Shared>,
34
    viewer: Viewer,
35
    Path((owner, name, r)): Path<(String, String, String)>,
36
    Query(q): Query<HistoryQuery>,
37
) -> Result<Page<HistoryPage>, Error> {
38
    let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer);
39
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
40
    let spec = r.clone();
41
    let path = q.path.clone().filter(|p| !p.is_empty());
42
    let offset = q.offset.unwrap_or(0);
43
    let h = app
44
        .repos
45
        .read(&repo, move |repo, cache| {
46
            let target = view::resolve(repo, cache, &spec)?;
47
            view::history(repo, cache, target, path.as_deref(), offset, PAGE)
48
        })
49
        .await
50
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
51
    let mut h = h;
52
    super::attribute(&app, &mut h.entries.iter_mut().collect::<Vec<_>>()).await;
53
    let graph_input: Vec<(String, Vec<String>)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
54
    let rows = graph::layout(&graph_input);
55
    let lanes = graph::width(&rows);
56
    Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes }))
57
}
58
59
/// A file's diff, rendered.
60
pub struct FileRows {
61
    pub path: String,
62
    pub from: Option<String>,
63
    pub kind: String,
64
    pub lines: Vec<diff::Line>,
65
    pub added: usize,
66
    pub removed: usize,
67
    pub anchor: String,
68
}
69
70
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
71
    render_files_with(files, bases, diff::Options::default())
72
}
73
74
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
75
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
76
    files
77
        .iter()
78
        .enumerate()
79
        .map(|(i, f)| {
80
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
81
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
82
            FileRows {
83
                path: f.path.clone(),
84
                from: f.from.clone(),
85
                kind: f.kind.clone(),
86
                added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(),
87
                removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(),
88
                lines,
89
                anchor: format!("diff-{i}"),
90
            }
91
        })
92
        .collect()
93
}
94
95
#[derive(Template)]
96
#[template(path = "change.html")]
97
pub struct ChangePage {
98
    shell: Shell,
99
    repo: RepoShell,
100
    c: view::ChangeDetail,
101
    files: Vec<FileRows>,
102
    body_html: String,
103
    ignore_ws: bool,
104
}
105
106
/// `?w=1` on a diff page hides whitespace-only changes.
107
#[derive(Deserialize, Default)]
108
pub struct DiffQuery {
109
    #[serde(default)]
110
    w: Option<String>,
111
}
112
113
impl DiffQuery {
114
    pub fn options(&self) -> diff::Options {
115
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
116
    }
117
}
118
119
pub async fn change(
120
    State(app): State<Shared>,
121
    viewer: Viewer,
122
    Path((owner, name, hash)): Path<(String, String, String)>,
123
    Query(q): Query<DiffQuery>,
124
) -> Result<Page<ChangePage>, Error> {
125
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
126
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
127
    let spec = hash.clone();
128
    let c = app
129
        .repos
130
        .read(&repo, move |repo, cache| {
131
            let h = view::resolve_change(repo, cache, &spec)?;
132
            view::change(repo, cache, &h)
133
        })
134
        .await
135
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
136
    let mut c = c;
137
    {
138
        let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view];
139
        all.extend(c.deps.iter_mut());
140
        all.extend(c.dependents.iter_mut());
141
        super::attribute(&app, &mut all).await;
142
    }
143
    let files = render_files_with(&c.files, &c.bases, q.options());
144
    let ignore_ws = q.options().ignore_whitespace;
145
    let body_html =
146
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
147
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws }))
148
}
149
150
#[derive(Template)]
151
#[template(path = "compare.html")]
152
pub struct ComparePage {
153
    shell: Shell,
154
    repo: RepoShell,
155
    cmp: view::Compare,
156
    files: Vec<FileRows>,
157
    ignore_ws: bool,
158
}
159
160
/// `/compare/{base}...{head}`.
161
pub async fn compare(
162
    State(app): State<Shared>,
163
    viewer: Viewer,
164
    Path((owner, name, spec)): Path<(String, String, String)>,
165
    Query(q): Query<DiffQuery>,
166
) -> Result<Page<ComparePage>, Error> {
167
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
168
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
169
    let Some((a, b)) = spec.split_once("...") else {
170
        return Err(Error::bad(shell, "compare takes base...head"));
171
    };
172
    let (a, b) = (a.to_string(), b.to_string());
173
    let cmp = app
174
        .repos
175
        .read(&repo, move |repo, cache| {
176
            let base = view::resolve(repo, cache, &a)?;
177
            let head = view::resolve(repo, cache, &b)?;
178
            view::compare(repo, cache, base, head)
179
        })
180
        .await
181
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
182
    let mut cmp = cmp;
183
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
184
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
185
    let ignore_ws = q.options().ignore_whitespace;
186
    Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), cmp, files, ignore_ws }))
187
}