OXYGENHUB
# 92574625ee9a oxygenhub / crates / hub / src / web / history.rs
13.1 KiB · Rust · last changed Swimlanes: a bookmark's changes as lanes of independent work 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
}
141
142
/// `GET /{owner}/{name}/lanes/{ref}`: a bookmark's newest changes as
143
/// swimlanes (hub spec §6.7): independent work in separate lanes, time
144
/// left to right, dependencies as edges and no merge nodes.
145
pub async fn lanes(
146
    State(app): State<Shared>,
147
    viewer: Viewer,
148
    Path((owner, name, r)): Path<(String, String, String)>,
149
) -> Result<Page<LanesPage>, Error> {
150
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
151
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
152
    let spec = r.clone();
153
    let h = app
154
        .repos
155
        .read(&repo, move |repo, cache| {
156
            let target = view::resolve(repo, cache, &spec)?;
157
            view::history(repo, cache, target, None, 0, BOARD)
158
        })
159
        .await
160
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
161
    let board = graph::board(&h.entries);
162
    Ok(Page(LanesPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), target: h.target, total: h.total, board }))
163
}
164
165
/// A file's diff, rendered.
166
pub struct FileRows {
167
    pub path: String,
168
    pub from: Option<String>,
169
    pub kind: String,
170
    pub lines: Vec<diff::Line>,
171
    pub added: usize,
172
    pub removed: usize,
173
    pub anchor: String,
174
}
175
176
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
177
    render_files_with(files, bases, diff::Options::default())
178
}
179
180
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
181
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
182
    files
183
        .iter()
184
        .enumerate()
185
        .map(|(i, f)| {
186
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
187
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
188
            FileRows {
189
                path: f.path.clone(),
190
                from: f.from.clone(),
191
                kind: f.kind.clone(),
192
                added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(),
193
                removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(),
194
                lines,
195
                anchor: format!("diff-{i}"),
196
            }
197
        })
198
        .collect()
199
}
200
201
#[derive(Template)]
202
#[template(path = "change.html")]
203
pub struct ChangePage {
204
    shell: Shell,
205
    repo: RepoShell,
206
    c: view::ChangeDetail,
207
    files: Vec<FileRows>,
208
    body_html: String,
209
    ignore_ws: bool,
210
}
211
212
/// `?w=1` on a diff page hides whitespace-only changes.
213
#[derive(Deserialize, Default)]
214
pub struct DiffQuery {
215
    #[serde(default)]
216
    w: Option<String>,
217
}
218
219
impl DiffQuery {
220
    pub fn options(&self) -> diff::Options {
221
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
222
    }
223
}
224
225
pub async fn change(
226
    State(app): State<Shared>,
227
    viewer: Viewer,
228
    Path((owner, name, hash)): Path<(String, String, String)>,
229
    Query(q): Query<DiffQuery>,
230
) -> Result<Page<ChangePage>, Error> {
231
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
232
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
233
    let spec = hash.clone();
234
    let c = app
235
        .repos
236
        .read(&repo, move |repo, cache| {
237
            let h = view::resolve_change(repo, cache, &spec)?;
238
            view::change(repo, cache, &h)
239
        })
240
        .await
241
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
242
    let mut c = c;
243
    {
244
        let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view];
245
        all.extend(c.deps.iter_mut());
246
        all.extend(c.dependents.iter_mut());
247
        super::attribute(&app, &mut all).await;
248
    }
249
    let files = render_files_with(&c.files, &c.bases, q.options());
250
    let ignore_ws = q.options().ignore_whitespace;
251
    let body_html =
252
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
253
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws }))
254
}
255
256
#[derive(Template)]
257
#[template(path = "compare.html")]
258
pub struct ComparePage {
259
    shell: Shell,
260
    repo: RepoShell,
261
    cmp: view::Compare,
262
    files: Vec<FileRows>,
263
    ignore_ws: bool,
264
    /// The two sides as given, for the pickers.
265
    base_spec: String,
266
    head_spec: String,
267
    /// Bookmarks and tags to pick from.
268
    bookmarks: Vec<String>,
269
    tags: Vec<String>,
270
    propose: Option<Propose>,
271
}
272
273
/// "Propose this": head into base, when both are bookmarks and the viewer may write.
274
pub struct Propose {
275
    pub url: String,
276
    /// An open proposal of head into base already.
277
    pub existing: Option<i64>,
278
}
279
280
#[derive(Deserialize)]
281
pub struct CompareForm {
282
    base: Option<String>,
283
    head: Option<String>,
284
}
285
286
/// `/compare`: the pickers' form target, or a sensible first comparison:
287
/// the most recently changed other bookmark against the default.
288
pub async fn compare_index(
289
    State(app): State<Shared>,
290
    viewer: Viewer,
291
    Path((owner, name)): Path<(String, String)>,
292
    Query(q): Query<CompareForm>,
293
) -> Result<Response, Error> {
294
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer);
295
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
296
    let clean = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("..."));
297
    let (base, head) = match (clean(q.base), clean(q.head)) {
298
        (Some(b), Some(h)) => (b, h),
299
        (b, h) => {
300
            let default = row.default_bookmark.clone();
301
            let d = default.clone();
302
            let newest = app
303
                .repos
304
                .read(&repo, move |r, cache| {
305
                    Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name))
306
                })
307
                .await
308
                .ok()
309
                .flatten();
310
            (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default)))
311
        }
312
    };
313
    let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23");
314
    Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response())
315
}
316
317
/// `/compare/{base}...{head}`.
318
pub async fn compare(
319
    State(app): State<Shared>,
320
    viewer: Viewer,
321
    Path((owner, name, spec)): Path<(String, String, String)>,
322
    Query(q): Query<DiffQuery>,
323
) -> Result<Page<ComparePage>, Error> {
324
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
325
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
326
    let Some((a, b)) = spec.split_once("...") else {
327
        return Err(Error::bad(shell, "compare takes base...head"));
328
    };
329
    let (a, b) = (a.to_string(), b.to_string());
330
    let cmp = app
331
        .repos
332
        .read(&repo, move |repo, cache| {
333
            let base = view::resolve(repo, cache, &a)?;
334
            let head = view::resolve(repo, cache, &b)?;
335
            view::compare(repo, cache, base, head)
336
        })
337
        .await
338
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
339
    let mut cmp = cmp;
340
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
341
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
342
    let ignore_ws = q.options().ignore_whitespace;
343
    let rs = RepoShell::new(&app, &row, "changes").with_role(role);
344
    let (bookmarks, tags) = app
345
        .repos
346
        .read(&repo, |r, _| {
347
            let b: Vec<String> = r.bookmarks()?.into_iter().map(|b| b.name).collect();
348
            let t: Vec<String> = view::tags(r)?.into_iter().map(|t| t.name).collect();
349
            Ok((b, t))
350
        })
351
        .await
352
        .unwrap_or_default();
353
    let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark;
354
    let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) {
355
        let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default();
356
        let existing = open
357
            .iter()
358
            .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label)
359
            .map(|p| p.number);
360
        Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing })
361
    } else {
362
        None
363
    };
364
    let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default();
365
    Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose }))
366
}