Implement simple tree browser and file view pages
Diff
Cargo.lock | 23 +++++++++++++++--------
Cargo.toml | 1 +
src/git.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 24 ++++++++++++++++++++----
statics/style.css | 5 +++++
src/methods/filters.rs | 4 ++++
src/methods/index.rs | 33 ++++++++++++++++-----------------
src/methods/repo.rs | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
templates/repo/file.html | 11 +++++++++++
templates/repo/tree.html | 28 ++++++++++++++++++++++++++++
10 files changed, 339 insertions(+), 117 deletions(-)
@@ -769,9 +769,9 @@
[[package]]
name = "hashbrown"
version = "0.12.1"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3"
checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022"
[[package]]
name = "hermit-abi"
@@ -842,9 +842,9 @@
[[package]]
name = "hyper"
version = "0.14.19"
version = "0.14.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42dc3c131584288d375f2d07f822b0cb012d8c6fb899a5b9fdb3cb7eb9b6004f"
checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac"
dependencies = [
"bytes",
"futures-channel",
@@ -1623,6 +1623,7 @@
"tower-service",
"tracing",
"tracing-subscriber",
"unix_mode",
"uuid",
]
@@ -1716,9 +1717,9 @@
[[package]]
name = "serde_yaml"
version = "0.8.24"
version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707d15895415db6628332b737c838b88c598522e4dc70647e59b72312924aebc"
checksum = "1ec0091e1f5aa338283ce049bd9dfefd55e1f168ac233e85c1ffe0038fb48cbe"
dependencies = [
"indexmap",
"ryu",
@@ -2107,9 +2108,9 @@
[[package]]
name = "triomphe"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda0abf5a9b5ad4a5ac1393956ae03fb57033749d3983e2cac9afbfd5ae04ec2"
checksum = "7fe1b3800b35f9b936c28dc59dbda91b195371269396784d931fe2a5a2be3d2f"
[[package]]
name = "try-lock"
@@ -2152,6 +2153,12 @@
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
[[package]]
name = "unix_mode"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35abed4630bb800f02451a7428205d1f37b8e125001471bfab259beee6a587ed"
[[package]]
name = "url"
@@ -29,4 +29,5 @@
tower-layer = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"
unix_mode = "0.1"
uuid = { version = "1.1", features = ["v4"] }
@@ -14,6 +14,7 @@
use parking_lot::Mutex;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use time::OffsetDateTime;
use tracing::instrument;
@@ -96,6 +97,73 @@
}
impl OpenRepository {
pub async fn path(self: Arc<Self>, path: Option<PathBuf>) -> PathDestination {
tokio::task::spawn_blocking(move || {
let repo = self.repo.lock();
let head = repo.head().unwrap();
let mut tree = head.peel_to_tree().unwrap();
if let Some(path) = path.as_ref() {
let item = tree.get_path(&path).unwrap();
let object = item.to_object(&repo).unwrap();
if let Some(blob) = object.as_blob() {
let name = item.name().unwrap().to_string();
let path = path.clone().join(&name);
let extension = path.extension()
.or(path.file_name())
.unwrap()
.to_string_lossy();
let content = format_file(blob.content(), &extension, &self.git.syntax_set);
return PathDestination::File(FileWithContent {
metadata: File {
mode: item.filemode(),
size: blob.size(),
path,
name,
},
content,
});
} else if let Ok(new_tree) = object.into_tree() {
tree = new_tree;
} else {
panic!("unknown item kind");
}
}
let mut tree_items = Vec::new();
for item in tree.iter() {
let object = item.to_object(&repo).unwrap();
let name = item.name().unwrap().to_string();
let path = path.clone().unwrap_or_default().join(&name);
if let Some(blob) = object.as_blob() {
tree_items.push(TreeItem::File(File {
mode: item.filemode(),
size: blob.size(),
path,
name,
}));
} else if let Some(_tree) = object.as_tree() {
tree_items.push(TreeItem::Tree(Tree {
mode: item.filemode(),
path,
name,
}));
}
}
PathDestination::Tree(tree_items)
})
.await
.unwrap()
}
#[instrument(skip(self))]
pub async fn tag_info(self: Arc<Self>, tag_name: &str) -> DetailedTag {
let reference = format!("refs/tags/{tag_name}");
@@ -287,6 +355,37 @@
.await
.unwrap()
}
}
pub enum PathDestination {
Tree(Vec<TreeItem>),
File(FileWithContent),
}
pub enum TreeItem {
Tree(Tree),
File(File),
}
#[derive(Debug)]
pub struct Tree {
pub mode: i32,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug)]
pub struct File {
pub mode: i32,
pub size: usize,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug)]
pub struct FileWithContent {
pub metadata: File,
pub content: String,
}
#[derive(Debug, Default)]
@@ -457,6 +556,24 @@
let diff_output = format_diff(&diff, &syntax_set);
(diff_output, diff_stats)
}
fn format_file(content: &[u8], extension: &str, syntax_set: &SyntaxSet) -> String {
let content = std::str::from_utf8(content).unwrap();
let syntax = syntax_set
.find_syntax_by_extension(&extension)
.unwrap_or(syntax_set.find_syntax_plain_text());
let mut html_generator =
ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced);
for line in LinesWithEndings::from(content) {
html_generator
.parse_html_for_line_which_includes_newline(line)
.unwrap();
}
html_generator.finalize()
}
#[instrument(skip(diff, syntax_set))]
@@ -1,11 +1,11 @@
#![deny(clippy::pedantic)]
use axum::{
body::Body, handler::Handler, http::HeaderValue, response::Response, routing::get, Extension,
Router,
};
use axum::{body::Body, handler::Handler, http::HeaderValue, response::Response, routing::get, Extension, Router, http};
use bat::assets::HighlightingAssets;
use std::sync::Arc;
use askama::Template;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use syntect::html::ClassStyle;
use tower_layer::layer_fn;
@@ -55,7 +55,21 @@
move || async move {
let mut resp = Response::new(Body::from(content));
resp.headers_mut()
.insert("Content-Type", HeaderValue::from_static("text/css"));
.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"));
resp
}
}
pub fn into_response<T: Template>(t: &T) -> Response {
match t.render() {
Ok(body) => {
let headers = [(
http::header::CONTENT_TYPE,
HeaderValue::from_static(T::MIME_TYPE),
)];
(headers, body).into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
@@ -110,3 +110,8 @@
background: #ffebe9;
display: block;
}
.nested-tree {
color: blue !important;
font-weight: bold;
}
@@ -1,3 +1,7 @@
pub fn timeago(s: time::OffsetDateTime) -> Result<String, askama::Error> {
Ok(timeago::Formatter::new().convert((time::OffsetDateTime::now_utc() - s).unsigned_abs()))
}
pub fn file_perms(s: &i32) -> Result<String, askama::Error> {
Ok(unix_mode::to_string(s.unsigned_abs()))
}
@@ -1,24 +1,21 @@
use askama::Template;
use axum::response::Html;
use axum::response::Response;
use axum::Extension;
use std::sync::Arc;
use super::filters;
use crate::{git::RepositoryMetadataList, Git};
#[allow(clippy::unused_async)]
pub async fn handle(Extension(git): Extension<Arc<Git>>) -> Html<String> {
#[derive(Template)]
#[template(path = "index.html")]
pub struct View {
pub repositories: Arc<RepositoryMetadataList>,
}
Html(
View {
repositories: git.fetch_repository_metadata().await,
}
.render()
.unwrap(),
)
use crate::{git::RepositoryMetadataList, Git, into_response};
#[derive(Template)]
#[template(path = "index.html")]
pub struct View {
pub repositories: Arc<RepositoryMetadataList>,
}
pub async fn handle(Extension(git): Extension<Arc<Git>>) -> Response {
let repositories = git.fetch_repository_metadata().await;
into_response(&View {
repositories,
})
}
@@ -9,7 +9,7 @@
extract::Query,
handler::Handler,
http::Request,
response::{Html, IntoResponse, Response},
response::{IntoResponse, Response},
Extension,
};
use path_clean::PathClean;
@@ -17,8 +17,8 @@
use tower::{util::BoxCloneService, Service};
use super::filters;
use crate::git::{DetailedTag, Refs};
use crate::{git::Commit, layers::UnwrapInfallible, Git};
use crate::git::{DetailedTag, FileWithContent, PathDestination, Refs, TreeItem};
use crate::{git::Commit, layers::UnwrapInfallible, Git, into_response};
#[derive(Clone)]
pub struct Repository(pub PathBuf);
@@ -34,6 +34,9 @@
#[derive(Clone)]
pub struct RepositoryPath(pub PathBuf);
#[derive(Clone)]
pub struct ChildPath(pub Option<PathBuf>);
impl Deref for RepositoryPath {
type Target = Path;
@@ -42,6 +45,7 @@
}
}
pub async fn service<ReqBody: Send + 'static>(mut request: Request<ReqBody>) -> Response {
let mut uri_parts: Vec<&str> = request
.uri()
@@ -51,6 +55,8 @@
.split('/')
.collect();
let mut child_path = None;
let mut service = match uri_parts.pop() {
Some("about") => BoxCloneService::new(handle_about.into_service()),
Some("refs") => BoxCloneService::new(handle_refs.into_service()),
@@ -61,7 +67,30 @@
Some("tag") => BoxCloneService::new(handle_tag.into_service()),
Some(v) => {
uri_parts.push(v);
BoxCloneService::new(handle_summary.into_service())
if uri_parts.iter().any(|v| *v == "tree") {
let mut reconstructed_path = Vec::new();
while let Some(part) = uri_parts.pop() {
if part == "tree" {
break;
}
reconstructed_path.insert(0, part);
}
child_path = Some(reconstructed_path.into_iter().collect::<PathBuf>().clean());
eprintln!("repo path: {:?}", child_path);
BoxCloneService::new(handle_tree.into_service())
} else {
BoxCloneService::new(handle_summary.into_service())
}
}
None => panic!("not found"),
};
@@ -69,6 +98,7 @@
let uri = uri_parts.into_iter().collect::<PathBuf>().clean();
let path = Path::new("../test-git").canonicalize().unwrap().join(&uri);
request.extensions_mut().insert(ChildPath(child_path));
request.extensions_mut().insert(Repository(uri));
request.extensions_mut().insert(RepositoryPath(path));
@@ -79,21 +109,28 @@
.into_response()
}
#[allow(clippy::unused_async)]
pub async fn handle_summary(Extension(repo): Extension<Repository>) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/summary.html")]
pub struct View {
repo: Repository,
}
#[derive(Template)]
#[template(path = "repo/summary.html")]
pub struct SummaryView {
pub repo: Repository,
}
Html(View { repo }.render().unwrap())
#[allow(clippy::unused_async)]
pub async fn handle_summary(Extension(repo): Extension<Repository>) -> Response {
into_response(&SummaryView { repo })
}
#[derive(Deserialize)]
pub struct TagQuery {
#[serde(rename = "h")]
name: String,
}
#[derive(Template)]
#[template(path = "repo/tag.html")]
pub struct TagView {
repo: Repository,
tag: DetailedTag,
}
pub async fn handle_tag(
@@ -101,18 +138,11 @@
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
Query(query): Query<TagQuery>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/tag.html")]
pub struct View {
repo: Repository,
tag: DetailedTag,
}
) -> Response {
let open_repo = git.repo(repository_path).await;
let tag = open_repo.tag_info(&query.name).await;
Html(View { repo, tag }.render().unwrap())
into_response(&TagView { repo, tag })
}
#[derive(Deserialize)]
@@ -123,75 +153,75 @@
branch: Option<String>,
}
#[allow(clippy::unused_async)]
#[derive(Template)]
#[template(path = "repo/log.html")]
pub struct LogView {
repo: Repository,
commits: Vec<Commit>,
next_offset: Option<usize>,
branch: Option<String>,
}
pub async fn handle_log(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
Query(query): Query<LogQuery>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/log.html")]
pub struct View {
repo: Repository,
commits: Vec<Commit>,
next_offset: Option<usize>,
branch: Option<String>,
}
) -> Response {
let open_repo = git.repo(repository_path).await;
let (commits, next_offset) = open_repo
.commits(query.branch.as_deref(), query.offset.unwrap_or(0))
.await;
Html(
View {
repo,
commits,
next_offset,
branch: query.branch,
}
.render()
.unwrap(),
)
into_response(&LogView {
repo,
commits,
next_offset,
branch: query.branch,
})
}
#[allow(clippy::unused_async)]
#[derive(Template)]
#[template(path = "repo/refs.html")]
pub struct RefsView {
repo: Repository,
refs: Arc<Refs>,
}
pub async fn handle_refs(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/refs.html")]
pub struct View {
repo: Repository,
refs: Arc<Refs>,
}
) -> Response {
let open_repo = git.repo(repository_path).await;
let refs = open_repo.refs().await;
Html(View { repo, refs }.render().unwrap())
into_response(&RefsView { repo, refs })
}
#[allow(clippy::unused_async)]
#[derive(Template)]
#[template(path = "repo/about.html")]
pub struct AboutView {
repo: Repository,
readme: Option<Arc<str>>,
}
pub async fn handle_about(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/about.html")]
pub struct View {
repo: Repository,
readme: Option<Arc<str>>,
}
) -> Response {
let open_repo = git.clone().repo(repository_path).await;
let readme = open_repo.readme().await;
into_response(&AboutView { repo, readme })
}
Html(View { repo, readme }.render().unwrap())
#[derive(Template)]
#[template(path = "repo/commit.html")]
pub struct CommitView {
pub repo: Repository,
pub commit: Arc<Commit>,
}
#[derive(Deserialize)]
@@ -199,20 +229,12 @@
id: Option<String>,
}
#[allow(clippy::unused_async)]
pub async fn handle_commit(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
Query(query): Query<CommitQuery>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/commit.html")]
pub struct View {
pub repo: Repository,
pub commit: Arc<Commit>,
}
) -> Response {
let open_repo = git.repo(repository_path).await;
let commit = if let Some(commit) = query.id {
open_repo.commit(&commit).await
@@ -220,34 +242,50 @@
Arc::new(open_repo.latest_commit().await)
};
Html(View { repo, commit }.render().unwrap())
into_response(&CommitView { repo, commit })
}
#[allow(clippy::unused_async)]
pub async fn handle_tree(Extension(repo): Extension<Repository>) -> Html<String> {
pub async fn handle_tree(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(ChildPath(child_path)): Extension<ChildPath>,
Extension(git): Extension<Arc<Git>>,
) -> Response {
#[derive(Template)]
#[template(path = "repo/tree.html")]
pub struct View {
pub struct TreeView {
pub repo: Repository,
pub items: Vec<TreeItem>,
}
#[derive(Template)]
#[template(path = "repo/file.html")]
pub struct FileView {
pub repo: Repository,
pub file: FileWithContent,
}
let open_repo = git.repo(repository_path).await;
match open_repo.path(child_path).await {
PathDestination::Tree(items) => into_response(&TreeView { repo, items }),
PathDestination::File(file) => into_response(&FileView { repo, file }),
}
}
Html(View { repo }.render().unwrap())
#[derive(Template)]
#[template(path = "repo/diff.html")]
pub struct DiffView {
pub repo: Repository,
pub commit: Arc<Commit>,
}
#[allow(clippy::unused_async)]
pub async fn handle_diff(
Extension(repo): Extension<Repository>,
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(git): Extension<Arc<Git>>,
Query(query): Query<CommitQuery>,
) -> Html<String> {
#[derive(Template)]
#[template(path = "repo/diff.html")]
pub struct View {
pub repo: Repository,
pub commit: Arc<Commit>,
}
) -> Response {
let open_repo = git.repo(repository_path).await;
let commit = if let Some(commit) = query.id {
open_repo.commit(&commit).await
@@ -255,5 +293,5 @@
Arc::new(open_repo.latest_commit().await)
};
Html(View { repo, commit }.render().unwrap())
into_response(&DiffView { repo, commit })
}
@@ -1,0 +1,11 @@
{% extends "repo/base.html" %}
{% block head %}
<link rel="stylesheet" type="text/css" href="/highlight.css" />
{% endblock %}
{% block tree_nav_class %}active{% endblock %}
{% block content %}
<pre>{{ file.content|safe }}</pre>
{% endblock %}
@@ -1,6 +1,34 @@
{% extends "repo/base.html" %}
{% block tree_nav_class %}active{% endblock %}
{% block content %}
<table class="repositories">
<thead>
<tr>
<th style="width: 10rem;">Mode</th>
<th>Name</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
{% match item %}
{% when crate::git::TreeItem::Tree with (tree) %}
<td><pre>{{ tree.mode|file_perms }}</pre></td>
<td><pre><a class="nested-tree" href="/{{ repo.display() }}/tree/{{ tree.path.display() }}">{{ tree.name }}</a></pre></td>
<td></td>
<td></td>
{% when crate::git::TreeItem::File with (file) %}
<td><pre>{{ file.mode|file_perms }}</pre></td>
<td><pre><a href="/{{ repo.display() }}/tree/{{ file.path.display() }}">{{ file.name }}</a></pre></td>
<td><pre>{{ file.size }}</pre></td>
<td></td>
{% endmatch %}
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}