OXYGENHUB
# e1ec9fbf538e oxygenhub / crates / hub / src / web / history.rs
9.5 KiB · Rust · last changed Compare page: bookmark and tag pickers, swap, /compare picks a sensible pair, and Propose this for writers 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
/// A file's diff, rendered.
61
pub struct FileRows {
62
    pub path: String,
63
    pub from: Option<String>,
64
    pub kind: String,
65
    pub lines: Vec<diff::Line>,
66
    pub added: usize,
67
    pub removed: usize,
68
    pub anchor: String,
69
}
70
71
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
72
    render_files_with(files, bases, diff::Options::default())
73
}
74
75
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
76
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
77
    files
78
        .iter()
79
        .enumerate()
80
        .map(|(i, f)| {
81
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
82
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
83
            FileRows {
84
                path: f.path.clone(),
85
                from: f.from.clone(),
86
                kind: f.kind.clone(),
87
                added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(),
88
                removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(),
89
                lines,
90
                anchor: format!("diff-{i}"),
91
            }
92
        })
93
        .collect()
94
}
95
96
#[derive(Template)]
97
#[template(path = "change.html")]
98
pub struct ChangePage {
99
    shell: Shell,
100
    repo: RepoShell,
101
    c: view::ChangeDetail,
102
    files: Vec<FileRows>,
103
    body_html: String,
104
    ignore_ws: bool,
105
}
106
107
/// `?w=1` on a diff page hides whitespace-only changes.
108
#[derive(Deserialize, Default)]
109
pub struct DiffQuery {
110
    #[serde(default)]
111
    w: Option<String>,
112
}
113
114
impl DiffQuery {
115
    pub fn options(&self) -> diff::Options {
116
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
117
    }
118
}
119
120
pub async fn change(
121
    State(app): State<Shared>,
122
    viewer: Viewer,
123
    Path((owner, name, hash)): Path<(String, String, String)>,
124
    Query(q): Query<DiffQuery>,
125
) -> Result<Page<ChangePage>, Error> {
126
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
127
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
128
    let spec = hash.clone();
129
    let c = app
130
        .repos
131
        .read(&repo, move |repo, cache| {
132
            let h = view::resolve_change(repo, cache, &spec)?;
133
            view::change(repo, cache, &h)
134
        })
135
        .await
136
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
137
    let mut c = c;
138
    {
139
        let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view];
140
        all.extend(c.deps.iter_mut());
141
        all.extend(c.dependents.iter_mut());
142
        super::attribute(&app, &mut all).await;
143
    }
144
    let files = render_files_with(&c.files, &c.bases, q.options());
145
    let ignore_ws = q.options().ignore_whitespace;
146
    let body_html =
147
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
148
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws }))
149
}
150
151
#[derive(Template)]
152
#[template(path = "compare.html")]
153
pub struct ComparePage {
154
    shell: Shell,
155
    repo: RepoShell,
156
    cmp: view::Compare,
157
    files: Vec<FileRows>,
158
    ignore_ws: bool,
159
    /// The two sides as given, for the pickers.
160
    base_spec: String,
161
    head_spec: String,
162
    /// Bookmarks and tags to pick from.
163
    bookmarks: Vec<String>,
164
    tags: Vec<String>,
165
    propose: Option<Propose>,
166
}
167
168
/// "Propose this": head into base, when both are bookmarks and the viewer may write.
169
pub struct Propose {
170
    pub url: String,
171
    /// An open proposal of head into base already.
172
    pub existing: Option<i64>,
173
}
174
175
#[derive(Deserialize)]
176
pub struct CompareForm {
177
    base: Option<String>,
178
    head: Option<String>,
179
}
180
181
/// `/compare`: the pickers' form target, or a sensible first comparison:
182
/// the most recently changed other bookmark against the default.
183
pub async fn compare_index(
184
    State(app): State<Shared>,
185
    viewer: Viewer,
186
    Path((owner, name)): Path<(String, String)>,
187
    Query(q): Query<CompareForm>,
188
) -> Result<Response, Error> {
189
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer);
190
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
191
    let clean = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("..."));
192
    let (base, head) = match (clean(q.base), clean(q.head)) {
193
        (Some(b), Some(h)) => (b, h),
194
        (b, h) => {
195
            let default = row.default_bookmark.clone();
196
            let d = default.clone();
197
            let newest = app
198
                .repos
199
                .read(&repo, move |r, cache| {
200
                    Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name))
201
                })
202
                .await
203
                .ok()
204
                .flatten();
205
            (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default)))
206
        }
207
    };
208
    let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23");
209
    Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response())
210
}
211
212
/// `/compare/{base}...{head}`.
213
pub async fn compare(
214
    State(app): State<Shared>,
215
    viewer: Viewer,
216
    Path((owner, name, spec)): Path<(String, String, String)>,
217
    Query(q): Query<DiffQuery>,
218
) -> Result<Page<ComparePage>, Error> {
219
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
220
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
221
    let Some((a, b)) = spec.split_once("...") else {
222
        return Err(Error::bad(shell, "compare takes base...head"));
223
    };
224
    let (a, b) = (a.to_string(), b.to_string());
225
    let cmp = app
226
        .repos
227
        .read(&repo, move |repo, cache| {
228
            let base = view::resolve(repo, cache, &a)?;
229
            let head = view::resolve(repo, cache, &b)?;
230
            view::compare(repo, cache, base, head)
231
        })
232
        .await
233
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
234
    let mut cmp = cmp;
235
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
236
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
237
    let ignore_ws = q.options().ignore_whitespace;
238
    let rs = RepoShell::new(&app, &row, "changes").with_role(role);
239
    let (bookmarks, tags) = app
240
        .repos
241
        .read(&repo, |r, _| {
242
            let b: Vec<String> = r.bookmarks()?.into_iter().map(|b| b.name).collect();
243
            let t: Vec<String> = view::tags(r)?.into_iter().map(|t| t.name).collect();
244
            Ok((b, t))
245
        })
246
        .await
247
        .unwrap_or_default();
248
    let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark;
249
    let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) {
250
        let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default();
251
        let existing = open
252
            .iter()
253
            .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label)
254
            .map(|p| p.number);
255
        Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing })
256
    } else {
257
        None
258
    };
259
    let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default();
260
    Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose }))
261
}