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