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