OXYGENHUB
# 19bb31ff1f5c oxygenhub / crates / hub / src / web / repo.rs
8.2 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
1
//! Repository overview, tree, file, raw and blame pages.
2
3
use super::{filters, lookup, Error, Page, RepoShell, Shell};
4
use crate::auth::Viewer;
5
use crate::render::{self, highlight};
6
use crate::repos::view::{self, EntryKind, FileBody};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Path, Query, State};
10
use axum::response::{IntoResponse, Response};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "overview.html")]
15
pub struct OverviewPage {
16
    shell: Shell,
17
    repo: RepoShell,
18
    o: view::Overview,
19
    readme_html: Option<String>,
20
}
21
22
pub async fn overview(
23
    State(app): State<Shared>,
24
    viewer: Viewer,
25
    Path((owner, name)): Path<(String, String)>,
26
) -> Result<Page<OverviewPage>, Error> {
27
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
28
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
29
    let default = row.default_bookmark.clone();
30
    let o =
31
        app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
32
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
33
    let readme_html = o.readme_html(&rs, &o.tree.target.label);
34
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html }))
35
}
36
37
impl view::Overview {
38
    fn readme_html(&self, rs: &RepoShell, r: &str) -> Option<String> {
39
        self.tree.readme.as_ref().map(|md| {
40
            let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
41
            render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url))
42
        })
43
    }
44
}
45
46
#[derive(Template)]
47
#[template(path = "tree.html")]
48
pub struct TreePage {
49
    shell: Shell,
50
    repo: RepoShell,
51
    t: view::Tree,
52
    readme_html: Option<String>,
53
}
54
55
pub async fn tree_root(
56
    State(app): State<Shared>,
57
    viewer: Viewer,
58
    Path((owner, name, r)): Path<(String, String, String)>,
59
) -> Result<Page<TreePage>, Error> {
60
    tree(State(app), viewer, Path((owner, name, r, String::new()))).await
61
}
62
63
pub async fn tree(
64
    State(app): State<Shared>,
65
    viewer: Viewer,
66
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
67
) -> Result<Page<TreePage>, Error> {
68
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tree/{r}/{path}"), &viewer);
69
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
70
    let (spec, dir) = (r.clone(), path.clone());
71
    let t = app
72
        .repos
73
        .read(&repo, move |repo, cache| {
74
            let target = view::resolve(repo, cache, &spec)?;
75
            view::tree(repo, cache, target, &dir)
76
        })
77
        .await
78
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
79
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
80
    let readme_html = t.readme.as_ref().map(|md| {
81
        let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
82
        render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url))
83
    });
84
    Ok(Page(TreePage { shell, repo: rs, t, readme_html }))
85
}
86
87
#[derive(Deserialize)]
88
pub struct FileQuery {
89
    plain: Option<String>,
90
    blame: Option<String>,
91
    /// `source` shows a Markdown file's text instead of rendering it.
92
    view: Option<String>,
93
}
94
95
#[derive(Template)]
96
#[template(path = "file.html")]
97
pub struct FilePage {
98
    shell: Shell,
99
    repo: RepoShell,
100
    f: view::FileView,
101
    /// Highlighted lines when text.
102
    lines: Vec<String>,
103
    language: Option<&'static str>,
104
    markdown_html: Option<String>,
105
    is_image: bool,
106
    is_markdown: bool,
107
    blame: Option<Vec<BlameRow>>,
108
}
109
110
/// A blame gutter entry: shown only on the first line of a run.
111
pub struct BlameRow {
112
    pub first: bool,
113
    pub hash: String,
114
    pub short: String,
115
    pub author: String,
116
    pub when_ms: u64,
117
    pub run: usize,
118
}
119
120
pub async fn file(
121
    State(app): State<Shared>,
122
    viewer: Viewer,
123
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
124
    Query(q): Query<FileQuery>,
125
) -> Result<Response, Error> {
126
    let shell = Shell::new(&app, &format!("/{owner}/{name}/file/{r}/{path}"), &viewer);
127
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
128
    let limit = app.config.limits.render_limit.0;
129
    let want_blame = q.blame.is_some();
130
    let (spec, p) = (r.clone(), path.clone());
131
    let (f, blame) = app
132
        .repos
133
        .read(&repo, move |repo, cache| {
134
            let target = view::resolve(repo, cache, &spec)?;
135
            if want_blame {
136
                let b = view::blame(repo, cache, target, &p, limit)?;
137
                Ok((b.file, Some(b.lines)))
138
            } else {
139
                Ok((view::file(repo, cache, target, &p, limit)?, None))
140
            }
141
        })
142
        .await
143
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
144
    if q.plain.is_some() {
145
        return Ok(super::raw(f.bytes, &f.name, true));
146
    }
147
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
148
    let is_image = super::mime_of(&f.name).starts_with("image/");
149
    let is_markdown = f.name.to_lowercase().ends_with(".md") || f.name.to_lowercase() == "readme";
150
    let want_source = q.view.as_deref() == Some("source") || blame.is_some();
151
    let (lines, language, markdown_html) = match &f.body {
152
        FileBody::Text(text) if !is_image => {
153
            if is_markdown && !want_source {
154
                let dir = f.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
155
                let base = format!("{}/file/{r}/{dir}", rs.url);
156
                (vec![], highlight::language(&f.path), Some(render::markdown::render(text, &base, &base)))
157
            } else {
158
                (highlight::lines(&f.path, text), highlight::language(&f.path), None)
159
            }
160
        }
161
        _ => (vec![], None, None),
162
    };
163
    let blame = blame.map(|lines| {
164
        let mut rows: Vec<BlameRow> = Vec::with_capacity(lines.len());
165
        let mut i = 0;
166
        while i < lines.len() {
167
            let mut run = 1;
168
            while i + run < lines.len() && lines[i + run].hash == lines[i].hash {
169
                run += 1;
170
            }
171
            for k in 0..run {
172
                let l = &lines[i + k];
173
                rows.push(BlameRow {
174
                    first: k == 0,
175
                    hash: l.hash.to_hex(),
176
                    short: l.hash.short(8),
177
                    author: l.author.clone(),
178
                    when_ms: l.when_ms,
179
                    run,
180
                });
181
            }
182
            i += run;
183
        }
184
        rows
185
    });
186
    Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame }).into_response())
187
}
188
189
#[derive(Deserialize)]
190
pub struct LastQuery {
191
    /// Comma-separated paths.
192
    paths: String,
193
}
194
195
/// `GET /{owner}/{name}/last/{ref}?paths=a,b` → JSON of last change per
196
/// path, fetched by the tree page after it paints (hub spec §6.5).
197
pub async fn last_changes(
198
    State(app): State<Shared>,
199
    viewer: Viewer,
200
    Path((owner, name, r)): Path<(String, String, String)>,
201
    Query(q): Query<LastQuery>,
202
) -> Result<Response, Error> {
203
    let shell = Shell::new(&app, "", &viewer);
204
    let (repo, _, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
205
    let paths: Vec<String> = q.paths.split(',').filter(|p| !p.is_empty()).take(500).map(String::from).collect();
206
    let spec = r.clone();
207
    let found = app
208
        .repos
209
        .read(&repo, move |repo, cache| {
210
            let target = view::resolve(repo, cache, &spec)?;
211
            view::last_changes(repo, cache, &target.heads, &paths)
212
        })
213
        .await
214
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
215
    let now = crate::db::now_ms() as u64;
216
    let body: Vec<serde_json::Value> = found
217
        .into_iter()
218
        .map(|(p, c)| {
219
            serde_json::json!({ "path": p, "hash": c.hash, "short": c.short, "title": c.title, "ago": render::time::ago(c.when_ms, now), "when": render::time::absolute(c.when_ms) })
220
        })
221
        .collect();
222
    Ok(axum::Json(body).into_response())
223
}
224
225
impl EntryKind {
226
    pub fn glyph(&self) -> &'static str {
227
        match self {
228
            EntryKind::Dir => "▸",
229
            EntryKind::File => "·",
230
            EntryKind::Symlink => "↪",
231
            EntryKind::Subrepo => "◫",
232
        }
233
    }
234
}