feat(git): html fragments for blame, blob, readme, commits, diff, languages, star and watch
ZhenYi committed
Modified lib/git/src/handler/html/badge.rs lib/git/src/handler/html/badge.rs +2 −1 View lib/git/src/handler/html/badge.rs
@@ -10,13 +10,14 @@
1010 get,
1111 path = "/repos/{space_name}/{repo_name}/html/badge",
1212 tag = "html",
13 operation_id = "render_badge",
1314 params(HtmlRepoPath),
1415 responses(
1516 (status = 200, description = "repo badge html fragment", content_type = "text/html"),
1617 (status = 404, description = "repo not found")
1718 )
1819)]
19pub async fn render(
20pub async fn render_badge(
2021 State(service): State<GitService>,
2122 Path(path): Path<HtmlRepoPath>,
2223 auth: BasicAuth,
Added lib/git/src/handler/html/blame.rs lib/git/src/handler/html/blame.rs +154 −0 View lib/git/src/handler/html/blame.rs
@@ -0,0 +1,154 @@
1use std::path::PathBuf;
2
3use axum::extract::{Path, Query, State};
4use axum::response::Response;
5use gitbaby::commit::types::TreeEntryMode;
6use maud::html;
7
8use crate::handler::html::error::html_err;
9use crate::handler::html::{
10 HtmlRepoPath, archived_class, resolve, response_html, short_sha, validate_path,
11};
12use crate::service::GitService;
13use crate::share::access::BasicAuth;
14use crate::share::validate::validate_revision;
15
16#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
17pub struct BlameQuery {
18 #[serde(default)]
19 pub revision: Option<String>,
20 #[serde(default)]
21 pub path: Option<String>,
22}
23
24#[utoipa::path(
25 get,
26 path = "/repos/{space_name}/{repo_name}/html/blame",
27 tag = "html",
28 operation_id = "render_blame",
29 params(HtmlRepoPath, BlameQuery),
30 responses(
31 (status = 200, description = "blame html fragment", content_type = "text/html"),
32 (status = 404, description = "repo, ref or blob not found")
33 )
34)]
35pub async fn render_blame(
36 State(service): State<GitService>,
37 Path(path): Path<HtmlRepoPath>,
38 Query(q): Query<BlameQuery>,
39 auth: BasicAuth,
40) -> Response {
41 let ctx = match resolve(service.clone(), path, auth).await {
42 Ok(c) => c,
43 Err(resp) => return resp,
44 };
45 let revision = q
46 .revision
47 .as_deref()
48 .unwrap_or(ctx.dto.default_branch.as_str())
49 .to_string();
50 if validate_revision(&revision).is_err() {
51 return html_err(404, "ref invalid");
52 }
53 let raw_path = q.path.unwrap_or_default();
54 let clean_path = match validate_path(&raw_path) {
55 Some(p) => p,
56 None => return html_err(404, "path invalid"),
57 };
58 if clean_path.is_empty() {
59 return html_err(404, "path required");
60 }
61
62 let blame_ranges = match service
63 .blame(
64 ctx.space_name.clone(),
65 ctx.repo_name.clone(),
66 revision.clone(),
67 PathBuf::from(clean_path.clone()),
68 None,
69 None,
70 )
71 .await
72 {
73 Ok(b) => b,
74 Err(e) => {
75 tracing::warn!(error = %e, "blame service internal");
76 return html_err(500, "internal");
77 }
78 };
79
80 let baby = service.baby(&ctx.ctx);
81 let entry = match baby
82 .tree_entry_at(&revision, std::path::Path::new(&clean_path))
83 .await
84 {
85 Ok(Some(e)) if e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec => e,
86 Ok(_) => return html_err(404, "blob not found"),
87 Err(_) => return html_err(404, "blob not found"),
88 };
89 let blob = match service
90 .blob_content(ctx.space_name.clone(), ctx.repo_name.clone(), entry.oid.to_string())
91 .await
92 {
93 Ok(b) => b,
94 Err(_) => return html_err(404, "failed to read blob"),
95 };
96
97 if blob.is_binary {
98 return html_err(400, "cannot blame binary file");
99 }
100
101 let text = String::from_utf8_lossy(&blob.data);
102 let lines: Vec<&str> = text.lines().collect();
103
104 let base = "git-blame overflow-x-auto rounded-lg border border-zinc-200 bg-white text-xs";
105 let cls = archived_class(&ctx.dto, base);
106 let space = ctx.space_name.clone();
107 let repo = ctx.repo_name.clone();
108
109 let markup = html! {
110 div class=(cls)
111 data-component="git-blame"
112 data-space=(space)
113 data-repo=(repo)
114 data-revision=(revision)
115 data-path=(clean_path.as_str())
116 data-lines=(lines.len())
117 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
118 table class="w-full font-mono border-collapse" {
119 tbody {
120 @for (line_idx, line) in lines.iter().enumerate() {
121 @let line_no = (line_idx + 1) as i32;
122 @let range = blame_ranges.iter().find(|r| line_no >= r.starting_line && line_no <= r.ending_line);
123 @let is_first_line_of_range = range.map(|r| r.starting_line == line_no).unwrap_or(false);
124 @let sha = range.map(|r| r.commit_sha.as_str()).unwrap_or("");
125 @let short = if sha.is_empty() { String::new() } else { short_sha(sha) };
126 @let commit_url = format!("/{space}/{repo}/commit/{sha}");
127
128 tr class="border-b border-zinc-100 hover:bg-zinc-50/80 group" id=(format!("L{line_no}")) {
129 td class="w-28 select-none border-r border-zinc-200 bg-zinc-50/60 px-2 py-0.5 text-left text-[11px] text-zinc-500 font-mono align-top" {
130 @if is_first_line_of_range && !sha.is_empty() {
131 a class="hover:underline font-medium text-zinc-700 hover:text-zinc-900"
132 href=(commit_url)
133 title=(sha) {
134 (short)
135 }
136 }
137 }
138 td class="w-12 select-none border-r border-zinc-200 bg-zinc-50/30 px-2 py-0.5 text-right text-zinc-400 group-hover:text-zinc-600 font-mono text-[11px]"
139 data-line=(line_no) {
140 a class="hover:underline text-zinc-400" href=(format!("#L{line_no}")) {
141 (line_no)
142 }
143 }
144 td class="whitespace-pre px-3 py-0.5 font-mono text-zinc-900 overflow-x-auto" {
145 (line)
146 }
147 }
148 }
149 }
150 }
151 }
152 };
153 response_html(markup, 30)
154}
Added lib/git/src/handler/html/blob_content.rs lib/git/src/handler/html/blob_content.rs +158 −0 View lib/git/src/handler/html/blob_content.rs
@@ -0,0 +1,158 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
4use maud::html;
5
6use crate::handler::html::error::html_err;
7use crate::handler::html::{
8 HtmlRepoPath, archived_class, format_size, resolve, response_html, validate_path,
9};
10use crate::service::GitService;
11use crate::share::access::BasicAuth;
12use crate::share::validate::validate_revision;
13
14#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
15pub struct BlobContentQuery {
16 #[serde(default)]
17 pub revision: Option<String>,
18 #[serde(default)]
19 pub path: Option<String>,
20}
21
22#[utoipa::path(
23 get,
24 path = "/repos/{space_name}/{repo_name}/html/blob-content",
25 tag = "html",
26 operation_id = "render_blob_content",
27 params(HtmlRepoPath, BlobContentQuery),
28 responses(
29 (status = 200, description = "blob content html fragment", content_type = "text/html"),
30 (status = 404, description = "repo, ref or blob not found")
31 )
32)]
33pub async fn render_blob_content(
34 State(service): State<GitService>,
35 Path(path): Path<HtmlRepoPath>,
36 Query(q): Query<BlobContentQuery>,
37 auth: BasicAuth,
38) -> Response {
39 let ctx = match resolve(service.clone(), path, auth).await {
40 Ok(c) => c,
41 Err(resp) => return resp,
42 };
43 let revision = q
44 .revision
45 .as_deref()
46 .unwrap_or(ctx.dto.default_branch.as_str())
47 .to_string();
48 if validate_revision(&revision).is_err() {
49 return html_err(404, "ref invalid");
50 }
51 let raw_path = q.path.unwrap_or_default();
52 let clean_path = match validate_path(&raw_path) {
53 Some(p) => p,
54 None => return html_err(404, "path invalid"),
55 };
56 if clean_path.is_empty() {
57 return html_err(404, "path required");
58 }
59
60 let baby = service.baby(&ctx.ctx);
61 let entry = match baby
62 .tree_entry_at(&revision, std::path::Path::new(&clean_path))
63 .await
64 {
65 Ok(Some(e)) if e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec || e.mode == TreeEntryMode::Symlink => e,
66 Ok(_) => return html_err(404, "blob not found"),
67 Err(_) => return html_err(404, "path not found"),
68 };
69
70 let blob = match service
71 .blob_content(ctx.space_name.clone(), ctx.repo_name.clone(), entry.oid.to_string())
72 .await
73 {
74 Ok(b) => b,
75 Err(_) => return html_err(404, "failed to load blob content"),
76 };
77
78 let base = "git-blob-content overflow-x-auto rounded-b-lg border-x border-b border-zinc-200 bg-white text-xs";
79 let cls = archived_class(&ctx.dto, base);
80 let raw_url = format!("/{}/{}/blob/raw/{revision}/{clean_path}", ctx.space_name, ctx.repo_name);
81
82 let file_name = clean_path.rsplit('/').next().unwrap_or(&clean_path).to_string();
83 let is_image = is_image_extension(&file_name);
84
85 let markup = html! {
86 div class=(cls)
87 data-component="git-blob-content"
88 data-space=(ctx.space_name)
89 data-repo=(ctx.repo_name)
90 data-revision=(revision)
91 data-path=(clean_path.as_str())
92 data-size=(blob.size)
93 data-binary=(if blob.is_binary { "true" } else { "false" })
94 data-truncated=(if blob.truncated { "true" } else { "false" })
95 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
96 @if is_image {
97 div class="flex items-center justify-center p-8 bg-zinc-50" {
98 img class="max-h-96 max-w-full rounded border border-zinc-200 object-contain shadow-sm"
99 src=(raw_url)
100 alt=(file_name.as_str());
101 }
102 } @else if blob.is_binary {
103 div class="flex flex-col items-center justify-center gap-3 p-12 text-zinc-500" {
104 svg class="h-10 w-10 text-zinc-400" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
105 path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113.25 16h-9.5A1.75 1.75 0 012 14.25V1.75z" {}
106 }
107 p class="text-sm font-medium text-zinc-700" { "Binary file not shown" }
108 p class="text-xs text-zinc-400" { (format_size(blob.size.max(0) as u64)) }
109 a class="rounded border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50 shadow-sm"
110 href=(raw_url)
111 download {
112 "Download raw binary"
113 }
114 }
115 } @else {
116 @let text = String::from_utf8_lossy(&blob.data);
117 table class="w-full font-mono text-xs leading-5 border-collapse" {
118 tbody {
119 @for (i, line) in text.lines().enumerate() {
120 @let line_no = i + 1;
121 tr class="hover:bg-zinc-50/80 group" id=(format!("L{line_no}")) {
122 td class="w-12 select-none border-r border-zinc-200 bg-zinc-50/50 px-2 py-0 text-right text-zinc-400 group-hover:text-zinc-600 font-mono text-[11px]"
123 data-line=(line_no) {
124 a class="hover:underline text-zinc-400" href=(format!("#L{line_no}")) {
125 (line_no)
126 }
127 }
128 td class="whitespace-pre px-3 py-0 font-mono text-zinc-900 overflow-x-auto" {
129 (line)
130 }
131 }
132 }
133 }
134 }
135 @if blob.truncated {
136 div class="border-t border-amber-200 bg-amber-50 p-2 text-center text-xs text-amber-700" {
137 "File is too large and has been truncated. "
138 a class="font-medium underline" href=(raw_url) { "Download raw file" }
139 }
140 }
141 }
142 }
143 };
144 response_html(markup, 30)
145}
146
147fn is_image_extension(filename: &str) -> bool {
148 let lower = filename.to_ascii_lowercase();
149 lower.ends_with(".png")
150 || lower.ends_with(".jpg")
151 || lower.ends_with(".jpeg")
152 || lower.ends_with(".gif")
153 || lower.ends_with(".svg")
154 || lower.ends_with(".webp")
155 || lower.ends_with(".ico")
156 || lower.ends_with(".bmp")
157 || lower.ends_with(".avif")
158}
Modified lib/git/src/handler/html/blob_header.rs lib/git/src/handler/html/blob_header.rs +19 −6 View lib/git/src/handler/html/blob_header.rs
@@ -20,13 +20,14 @@
2020 get,
2121 path = "/repos/{space_name}/{repo_name}/html/blob-header",
2222 tag = "html",
23 operation_id = "render_blob_header",
2324 params(HtmlRepoPath, BlobHeaderQuery),
2425 responses(
2526 (status = 200, description = "blob header html fragment", content_type = "text/html"),
2627 (status = 404, description = "repo, ref or blob not found")
2728 )
2829)]
29pub async fn render(
30pub async fn render_blob_header(
3031 State(service): State<GitService>,
3132 Path(path): Path<HtmlRepoPath>,
3233 Query(q): Query<BlobHeaderQuery>,
@@ -58,6 +59,8 @@
5859 let space = ctx.space_name.clone();
5960 let repo = ctx.repo_name.clone();
6061 let raw_url = format!("/{space}/{repo}/blob/raw/{revision}/{clean_path}");
62 let blame_url = format!("/{space}/{repo}/blame/{revision}/{clean_path}");
63 let history_url = format!("/{space}/{repo}/commits/{revision}/{clean_path}");
6164 let markup = html! {
6265 div class=(cls)
6366 data-component="git-blob-header"
@@ -70,12 +73,22 @@
7073 svg class="h-4 w-4 text-zinc-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
7174 path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" {}
7275 }
73 span class="font-mono text-sm text-zinc-700" { (filename) }
76 span class="font-mono text-sm font-medium text-zinc-700" { (filename) }
7477 }
75 div class="flex items-center gap-2" {
76 a class="rounded border border-zinc-200 px-2 py-0.5 text-xs hover:bg-zinc-50"
77 href=(raw_url) {
78 "raw"
78 div class="flex items-center gap-1.5" {
79 a class="rounded border border-zinc-200 bg-white px-2 py-0.5 text-xs text-zinc-700 hover:bg-zinc-50"
80 href=(raw_url)
81 target="_blank"
82 rel="noopener noreferrer" {
83 "Raw"
84 }
85 a class="rounded border border-zinc-200 bg-white px-2 py-0.5 text-xs text-zinc-700 hover:bg-zinc-50"
86 href=(blame_url) {
87 "Blame"
88 }
89 a class="rounded border border-zinc-200 bg-white px-2 py-0.5 text-xs text-zinc-700 hover:bg-zinc-50"
90 href=(history_url) {
91 "History"
7992 }
8093 }
8194 }
Modified lib/git/src/handler/html/breadcrumbs.rs lib/git/src/handler/html/breadcrumbs.rs +2 −1 View lib/git/src/handler/html/breadcrumbs.rs
@@ -20,13 +20,14 @@
2020 get,
2121 path = "/repos/{space_name}/{repo_name}/html/breadcrumbs",
2222 tag = "html",
23 operation_id = "render_breadcrumbs",
2324 params(HtmlRepoPath, BreadcrumbsQuery),
2425 responses(
2526 (status = 200, description = "path breadcrumbs html fragment", content_type = "text/html"),
2627 (status = 404, description = "repo or ref not found")
2728 )
2829)]
29pub async fn render(
30pub async fn render_breadcrumbs(
3031 State(service): State<GitService>,
3132 Path(path): Path<HtmlRepoPath>,
3233 Query(q): Query<BreadcrumbsQuery>,
Modified lib/git/src/handler/html/clone_buttons.rs lib/git/src/handler/html/clone_buttons.rs +2 −1 View lib/git/src/handler/html/clone_buttons.rs
@@ -10,13 +10,14 @@
1010 get,
1111 path = "/repos/{space_name}/{repo_name}/html/clone-buttons",
1212 tag = "html",
13 operation_id = "render_clone_buttons",
1314 params(HtmlRepoPath),
1415 responses(
1516 (status = 200, description = "clone buttons html fragment", content_type = "text/html"),
1617 (status = 404, description = "repo not found")
1718 )
1819)]
19pub async fn render(
20pub async fn render_clone_buttons(
2021 State(service): State<GitService>,
2122 Path(path): Path<HtmlRepoPath>,
2223 auth: BasicAuth,
Added lib/git/src/handler/html/commit_diff.rs lib/git/src/handler/html/commit_diff.rs +313 −0 View lib/git/src/handler/html/commit_diff.rs
@@ -0,0 +1,313 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use maud::{Markup, html};
4
5use crate::handler::html::error::html_err;
6use crate::handler::html::{
7 HtmlRepoPath, archived_class, resolve, response_html, short_sha,
8};
9use crate::service::diffs::ChangedPathDiffDto;
10use crate::service::GitService;
11use crate::share::access::BasicAuth;
12
13const MAX_FILES: usize = 50;
14const MAX_LINES_PER_FILE: usize = 2000;
15
16#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
17pub struct CommitDiffQuery {
18 #[serde(default)]
19 pub commit: Option<String>,
20 #[serde(default)]
21 pub style: Option<String>,
22}
23
24#[utoipa::path(
25 get,
26 path = "/repos/{space_name}/{repo_name}/html/commit-diff",
27 tag = "html",
28 operation_id = "render_commit_diff",
29 params(HtmlRepoPath, CommitDiffQuery),
30 responses(
31 (status = 200, description = "commit diff html fragment", content_type = "text/html"),
32 (status = 404, description = "repo or commit not found")
33 )
34)]
35pub async fn render_commit_diff(
36 State(service): State<GitService>,
37 Path(path): Path<HtmlRepoPath>,
38 Query(q): Query<CommitDiffQuery>,
39 auth: BasicAuth,
40) -> Response {
41 let ctx = match resolve(service.clone(), path, auth).await {
42 Ok(c) => c,
43 Err(resp) => return resp,
44 };
45 let commit = match q.commit.as_deref() {
46 Some(c) if !c.trim().is_empty() => c.trim().to_string(),
47 _ => return html_err(404, "commit required"),
48 };
49 if commit.len() > 256 || commit.contains(['\0', ' ', '\\']) {
50 return html_err(404, "commit invalid");
51 }
52
53 let (base, files) = match ctx.service.commit_diff_for_html(&ctx.ctx, &commit).await {
54 Ok(v) => v,
55 Err(e) => {
56 tracing::warn!(error = %e, "commit diff internal");
57 return html_err(500, "internal");
58 }
59 };
60 let style = match q.style.as_deref() {
61 Some("split") => "split",
62 Some("unified") => "unified",
63 _ => {
64 let pref = ctx.service.user_diff_view_style(ctx.uid).await;
65 if pref == "split" { "split" } else { "unified" }
66 }
67 };
68
69 let total_files = files.len();
70 let truncated_files = total_files > MAX_FILES;
71 let shown: Vec<&ChangedPathDiffDto> = files.iter().take(MAX_FILES).collect();
72 let commit_short = short_sha(&commit);
73 let base_short = short_sha(&base);
74 let is_root = base == "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
75
76 let base_cls = "git-commit-diff flex flex-col gap-4";
77 let cls = archived_class(&ctx.dto, base_cls);
78 let space = ctx.space_name.clone();
79 let repo = ctx.repo_name.clone();
80
81 let markup = html! {
82 div class=(cls)
83 data-component="git-commit-diff"
84 data-space=(space)
85 data-repo=(repo)
86 data-commit=(commit)
87 data-base=(base)
88 data-diff-style=(style)
89 data-total-files=(total_files)
90 data-truncated-files=(if truncated_files { "true" } else { "false" })
91 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
92 div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500 font-mono" {
93 span class="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-700" { (commit_short) }
94 span { "vs" }
95 @if is_root {
96 span class="rounded bg-zinc-100 px-1.5 py-0.5" { "root (empty tree)" }
97 } @else {
98 span class="rounded bg-zinc-100 px-1.5 py-0.5" { (base_short) }
99 }
100 span class="ml-auto" { (total_files) " changed files" }
101 }
102 @for f in &shown {
103 (render_file(f, style))
104 }
105 @if truncated_files {
106 div class="rounded-lg border border-dashed border-zinc-300 bg-zinc-50 p-4 text-center text-xs text-zinc-500" {
107 (total_files - MAX_FILES) " more files not shown"
108 }
109 }
110 }
111 };
112 response_html(markup, 30)
113}
114
115struct DiffRow {
116 old_no: Option<u32>,
117 new_no: Option<u32>,
118 kind: RowKind,
119 content: String,
120}
121
122enum RowKind {
123 Add,
124 Del,
125 Ctx,
126}
127
128fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
129 let rest = line.strip_prefix("@@ ")?.split(" @@").next()?;
130 let mut parts = rest.split(' ');
131 let old = parts.next()?.trim_start_matches('-');
132 let new = parts.next()?.trim_start_matches('+');
133 let old_start = old.split(',').next()?.parse().ok()?;
134 let new_start = new.split(',').next()?.parse().ok()?;
135 Some((old_start, new_start))
136}
137
138fn parse_patch_rows(patch: &str, cap: usize) -> (Vec<DiffRow>, bool) {
139 let mut rows = Vec::new();
140 let mut old_no = 0u32;
141 let mut new_no = 0u32;
142 let mut in_hunk = false;
143 let mut truncated = false;
144 for line in patch.lines() {
145 if truncated {
146 break;
147 }
148 if line.starts_with("@@") {
149 if let Some((o, n)) = parse_hunk_header(line) {
150 old_no = o;
151 new_no = n;
152 }
153 in_hunk = true;
154 continue;
155 }
156 if !in_hunk {
157 continue;
158 }
159 if line.starts_with("\\") {
160 continue;
161 }
162 if rows.len() >= cap {
163 truncated = true;
164 break;
165 }
166 if let Some(rest) = line.strip_prefix('+') {
167 rows.push(DiffRow { old_no: None, new_no: Some(new_no), kind: RowKind::Add, content: rest.to_string() });
168 new_no = new_no.saturating_add(1);
169 } else if let Some(rest) = line.strip_prefix('-') {
170 rows.push(DiffRow { old_no: Some(old_no), new_no: None, kind: RowKind::Del, content: rest.to_string() });
171 old_no = old_no.saturating_add(1);
172 } else {
173 let rest = line.strip_prefix(' ').unwrap_or(line);
174 rows.push(DiffRow { old_no: Some(old_no), new_no: Some(new_no), kind: RowKind::Ctx, content: rest.to_string() });
175 old_no = old_no.saturating_add(1);
176 new_no = new_no.saturating_add(1);
177 }
178 }
179 (rows, truncated)
180}
181
182fn status_badge(status: &str) -> (&'static str, &'static str) {
183 match status {
184 "Added" => ("A", "bg-emerald-100 text-emerald-700"),
185 "Deleted" => ("D", "bg-red-100 text-red-700"),
186 "Renamed" => ("R", "bg-sky-100 text-sky-700"),
187 "Copied" => ("C", "bg-sky-100 text-sky-700"),
188 "TypeChanged" => ("T", "bg-amber-100 text-amber-700"),
189 _ => ("M", "bg-zinc-100 text-zinc-700"),
190 }
191}
192
193fn render_file(f: &ChangedPathDiffDto, style: &str) -> Markup {
194 let (badge, badge_cls) = status_badge(&f.path.status);
195 let display_path = match (&f.path.old_path, &f.path.new_path) {
196 (Some(old), new) if old != new => format!("{old} → {new}"),
197 (_, new) => new.clone(),
198 };
199 let (rows, truncated) = match (&f.patch, f.binary) {
200 (Some(p), false) => parse_patch_rows(p, MAX_LINES_PER_FILE),
201 _ => (Vec::new(), false),
202 };
203 html! {
204 div class="rounded-lg border border-zinc-200 bg-white overflow-hidden"
205 data-file=(f.path.new_path)
206 data-status=(f.path.status)
207 data-additions=(f.lines_added)
208 data-deletions=(f.lines_removed)
209 data-binary=(if f.binary { "true" } else { "false" }) {
210 div class="flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-2" {
211 span class={ "rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold " (badge_cls) } { (badge) }
212 span class="font-mono text-xs text-zinc-800 truncate" { (display_path) }
213 span class="ml-auto font-mono text-xs" {
214 span class="text-emerald-600" { "+" (f.lines_added) }
215 " "
216 span class="text-red-600" { "−" (f.lines_removed) }
217 }
218 }
219 @if f.binary {
220 div class="p-3 text-center text-xs text-zinc-500 bg-zinc-50" {
221 "Binary file not shown"
222 }
223 } @else if rows.is_empty() {
224 div class="p-3 text-center text-xs text-zinc-500 bg-zinc-50" {
225 "No content changes"
226 }
227 } @else if style == "split" {
228 (render_split(&rows))
229 } @else {
230 (render_unified(&rows))
231 }
232 @if truncated {
233 div class="border-t border-zinc-200 bg-zinc-50 px-3 py-1.5 text-center text-[11px] text-zinc-500" {
234 "diff truncated"
235 }
236 }
237 }
238 }
239}
240
241fn render_unified(rows: &[DiffRow]) -> Markup {
242 html! {
243 div class="overflow-x-auto" {
244 table class="w-full border-collapse text-xs font-mono" {
245 tbody {
246 @for r in rows {
247 @let (row_cls, marker) = match r.kind {
248 RowKind::Add => ("bg-emerald-50", "+"),
249 RowKind::Del => ("bg-red-50", "−"),
250 RowKind::Ctx => ("", " "),
251 };
252 tr class=(row_cls) {
253 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.old_no.map(|n| n.to_string()).unwrap_or_default()) }
254 td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.new_no.map(|n| n.to_string()).unwrap_or_default()) }
255 td class="w-4 select-none text-center" { (marker) }
256 td class="whitespace-pre px-2 py-0 text-zinc-900" { (r.content) }
257 }
258 }
259 }
260 }
261 }
262 }
263}
264
265fn render_split(rows: &[DiffRow]) -> Markup {
266 fn flush(
267 dels: &mut Vec<usize>,
268 adds: &mut Vec<usize>,
269 pairs: &mut Vec<(Option<usize>, Option<usize>)>,
270 ) {
271 let n = dels.len().max(adds.len());
272 for i in 0..n {
273 pairs.push((dels.get(i).copied(), adds.get(i).copied()));
274 }
275 dels.clear();
276 adds.clear();
277 }
278 let mut pairs: Vec<(Option<usize>, Option<usize>)> = Vec::new();
279 let mut dels: Vec<usize> = Vec::new();
280 let mut adds: Vec<usize> = Vec::new();
281 for (i, r) in rows.iter().enumerate() {
282 match r.kind {
283 RowKind::Del => dels.push(i),
284 RowKind::Add => adds.push(i),
285 RowKind::Ctx => {
286 flush(&mut dels, &mut adds, &mut pairs);
287 pairs.push((Some(i), Some(i)));
288 }
289 }
290 }
291 flush(&mut dels, &mut adds, &mut pairs);
292
293 html! {
294 div class="overflow-x-auto" {
295 table class="w-full border-collapse text-xs font-mono" {
296 tbody {
297 @for (old_i, new_i) in pairs {
298 @let old = old_i.map(|i| &rows[i]);
299 @let new = new_i.map(|i| &rows[i]);
300 @let old_cls = if old.is_some_and(|r| matches!(r.kind, RowKind::Del)) { "bg-red-50" } else { "" };
301 @let new_cls = if new.is_some_and(|r| matches!(r.kind, RowKind::Add)) { "bg-emerald-50" } else { "" };
302 tr {
303 td class={ "w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400 " (old_cls) } { (old.and_then(|r| r.old_no).map(|n| n.to_string()).unwrap_or_default()) }
304 td class={ "w-1/2 whitespace-pre px-2 py-0 border-r border-zinc-200 text-zinc-900 " (old_cls) } { (old.map(|r| r.content.as_str()).unwrap_or("")) }
305 td class={ "w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400 " (new_cls) } { (new.and_then(|r| r.new_no).map(|n| n.to_string()).unwrap_or_default()) }
306 td class={ "w-1/2 whitespace-pre px-2 py-0 text-zinc-900 " (new_cls) } { (new.map(|r| r.content.as_str()).unwrap_or("")) }
307 }
308 }
309 }
310 }
311 }
312 }
313}
Added lib/git/src/handler/html/commits.rs lib/git/src/handler/html/commits.rs +195 −0 View lib/git/src/handler/html/commits.rs
@@ -0,0 +1,195 @@
1use std::path::PathBuf;
2
3use axum::extract::{Path, Query, State};
4use axum::response::Response;
5use maud::html;
6
7use crate::handler::html::error::html_err;
8use crate::handler::html::{
9 HtmlRepoPath, archived_class, clamp_limit, first_line, iso_time_unix, relative_time_unix,
10 resolve,
11 response_html, short_sha, validate_path,
12};
13use crate::service::GitService;
14use crate::service::commits::ListCommitsCmd;
15use crate::share::access::BasicAuth;
16use crate::share::validate::validate_revision;
17
18#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
19pub struct CommitsQuery {
20 #[serde(default)]
21 pub revision: Option<String>,
22 #[serde(default)]
23 pub path: Option<String>,
24 #[serde(default)]
25 pub limit: Option<usize>,
26 #[serde(default)]
27 pub skip: Option<usize>,
28}
29
30#[utoipa::path(
31 get,
32 path = "/repos/{space_name}/{repo_name}/html/commits",
33 tag = "html",
34 operation_id = "render_commits",
35 params(HtmlRepoPath, CommitsQuery),
36 responses(
37 (status = 200, description = "commits list html fragment", content_type = "text/html"),
38 (status = 404, description = "repo or ref not found")
39 )
40)]
41pub async fn render_commits(
42 State(service): State<GitService>,
43 Path(path): Path<HtmlRepoPath>,
44 Query(q): Query<CommitsQuery>,
45 auth: BasicAuth,
46) -> Response {
47 let ctx = match resolve(service.clone(), path, auth).await {
48 Ok(c) => c,
49 Err(resp) => return resp,
50 };
51 let revision = q
52 .revision
53 .as_deref()
54 .unwrap_or(ctx.dto.default_branch.as_str())
55 .to_string();
56 if validate_revision(&revision).is_err() {
57 return html_err(404, "ref invalid");
58 }
59 let raw_path = q.path.unwrap_or_default();
60 let clean_path = match validate_path(&raw_path) {
61 Some(p) => p,
62 None => return html_err(404, "path invalid"),
63 };
64 let limit = clamp_limit(q.limit).min(100);
65
66 let cmd = ListCommitsCmd {
67 revision: revision.clone(),
68 path: if clean_path.is_empty() { None } else { Some(PathBuf::from(clean_path.clone())) },
69 max_count: Some(limit as u32),
70 skip: q.skip.map(|s| s as u32),
71 since: None,
72 until: None,
73 first_parent: false,
74 };
75
76 let page = match service
77 .list_commits(ctx.space_name.clone(), ctx.repo_name.clone(), cmd, ctx.uid)
78 .await
79 {
80 Ok(p) => p,
81 Err(e) => {
82 tracing::warn!(error = %e, "commits list internal");
83 return html_err(500, "internal");
84 }
85 };
86 let commits = page.commits;
87
88 let base = "git-commits w-full rounded-lg border border-zinc-200 bg-white overflow-hidden text-sm";
89 let cls = archived_class(&ctx.dto, base);
90 let space = ctx.space_name.clone();
91 let repo = ctx.repo_name.clone();
92 let skip = page.skip as usize;
93 let older_skip = skip + limit;
94 let has_older = page.has_more;
95
96 let markup = html! {
97 div class=(cls)
98 data-component="git-commits"
99 data-space=(space)
100 data-repo=(repo)
101 data-revision=(revision)
102 data-path=(clean_path.as_str())
103 data-count=(commits.len())
104 data-limit=(page.limit)
105 data-skip=(page.skip)
106 data-total=(page.total)
107 data-has-more=(if page.has_more { "true" } else { "false" })
108 data-diff-style=(page.diff_view_style)
109 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
110 div class="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs text-zinc-500 font-medium" {
111 span { "Commit history" }
112 span class="font-mono text-[11px]" {
113 @if page.total == 0 {
114 "0 commits"
115 } @else {
116 (skip + 1) "–" (skip + commits.len()) " of " (page.total) " commits"
117 }
118 }
119 }
120 @if commits.is_empty() {
121 div class="p-8 text-center text-xs text-zinc-500" {
122 "No commits found"
123 }
124 } @else {
125 ul class="divide-y divide-zinc-100" {
126 @for c in &commits {
127 @let sha = c.id.clone();
128 @let short = short_sha(&sha);
129 @let subject = first_line(&c.message);
130 @let author_name = c.author_name.clone();
131 @let time_iso = iso_time_unix(c.author_time);
132 @let time_rel = relative_time_unix(c.author_time);
133 @let commit_url = format!("/{space}/{repo}/commit/{sha}");
134 @let tree_url = format!("/{space}/{repo}/tree/{sha}");
135 li class="flex items-center justify-between gap-4 p-3 hover:bg-zinc-50 transition-colors" {
136 div class="flex flex-col gap-1 min-w-0 flex-1" {
137 a class="font-medium text-zinc-900 hover:text-zinc-600 truncate text-sm"
138 href=(commit_url)
139 title=(format!("{sha}\n{subject}\n{author_name}\n{time_iso}")) {
140 (subject)
141 }
142 div class="flex items-center gap-2 text-xs text-zinc-500" {
143 span class="font-medium text-zinc-700" { (author_name) }
144 span { "committed" }
145 time datetime=(time_iso) title=(time_iso) { (time_rel) }
146 }
147 }
148 div class="flex items-center gap-2 shrink-0 font-mono text-xs" {
149 a class="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-zinc-700 hover:bg-zinc-100 font-mono"
150 href=(commit_url) {
151 (short)
152 }
153 a class="rounded border border-zinc-200 bg-white p-1 text-zinc-500 hover:text-zinc-800 hover:bg-zinc-50"
154 href=(tree_url)
155 title="Browse repository at this point" {
156 svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
157 path d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L14.94 8l-3.72-3.72a.75.75 0 010-1.06z" {}
158 }
159 }
160 }
161 }
162 }
163 }
164 }
165 @if page.total > page.limit.into() {
166 div class="flex items-center justify-between border-t border-zinc-200 bg-zinc-50 px-4 py-2 text-xs" {
167 @if skip > 0 {
168 a class="rounded border border-zinc-200 bg-white px-3 py-1 font-medium text-zinc-700 hover:bg-zinc-100"
169 href=(format!("?revision={}&path={}&limit={}&skip={}", revision, clean_path, limit, skip.saturating_sub(limit))) {
170 "Newer"
171 }
172 } @else {
173 span class="rounded border border-zinc-200 bg-zinc-50 px-3 py-1 font-medium text-zinc-300 cursor-default" {
174 "Newer"
175 }
176 }
177 span class="text-zinc-500 font-mono" {
178 "page " (skip / limit + 1) " / " ((page.total as usize + limit - 1) / limit.max(1))
179 }
180 @if has_older {
181 a class="rounded border border-zinc-200 bg-white px-3 py-1 font-medium text-zinc-700 hover:bg-zinc-100"
182 href=(format!("?revision={}&path={}&limit={}&skip={}", revision, clean_path, limit, older_skip)) {
183 "Older"
184 }
185 } @else {
186 span class="rounded border border-zinc-200 bg-zinc-50 px-3 py-1 font-medium text-zinc-300 cursor-default" {
187 "Older"
188 }
189 }
190 }
191 }
192 }
193 };
194 response_html(markup, 30)
195}
Modified lib/git/src/handler/html/header.rs lib/git/src/handler/html/header.rs +2 −1 View lib/git/src/handler/html/header.rs
@@ -10,13 +10,14 @@
1010 get,
1111 path = "/repos/{space_name}/{repo_name}/html/header",
1212 tag = "html",
13 operation_id = "render_header",
1314 params(HtmlRepoPath),
1415 responses(
1516 (status = 200, description = "repo header html fragment", content_type = "text/html"),
1617 (status = 404, description = "repo not found")
1718 )
1819)]
19pub async fn render(
20pub async fn render_header(
2021 State(service): State<GitService>,
2122 Path(path): Path<HtmlRepoPath>,
2223 auth: BasicAuth,
Added lib/git/src/handler/html/languages.rs lib/git/src/handler/html/languages.rs +113 −0 View lib/git/src/handler/html/languages.rs
@@ -0,0 +1,113 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/languages",
12 tag = "html",
13 operation_id = "render_languages",
14 params(HtmlRepoPath),
15 responses(
16 (status = 200, description = "languages analysis html fragment", content_type = "text/html"),
17 (status = 404, description = "repo not found")
18 )
19)]
20pub async fn render_languages(
21 State(service): State<GitService>,
22 Path(path): Path<HtmlRepoPath>,
23 auth: BasicAuth,
24) -> Response {
25 let ctx = match resolve(service.clone(), path, auth).await {
26 Ok(c) => c,
27 Err(resp) => return resp,
28 };
29
30 let mut langs = service
31 .analysis_languages_read(ctx.space_name.clone(), ctx.repo_name.clone())
32 .await
33 .unwrap_or_default();
34
35 if langs.is_empty() {
36 langs = service
37 .analysis_languages(ctx.space_name.clone(), ctx.repo_name.clone())
38 .await
39 .unwrap_or_default();
40 }
41
42 let base = "git-languages flex flex-col gap-2 rounded-lg border border-zinc-200 bg-white p-3 text-xs";
43 let cls = archived_class(&ctx.dto, base);
44
45 let markup = html! {
46 div class=(cls)
47 data-component="git-languages"
48 data-space=(ctx.space_name)
49 data-repo=(ctx.repo_name)
50 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
51 div class="flex items-center justify-between font-medium text-zinc-700" {
52 span { "Languages" }
53 @if let Some(primary) = langs.iter().find(|l| l.is_primary) {
54 span class="text-[11px] text-zinc-500" { "Primary: " (primary.language) }
55 }
56 }
57 @if langs.is_empty() {
58 p class="text-zinc-400 text-xs py-1" { "No language data available" }
59 } @else {
60 div class="flex h-2 w-full overflow-hidden rounded-full bg-zinc-100" {
61 @for l in &langs {
62 @let pct = (l.percentage_bp as f32) / 100.0;
63 @let color = language_color(&l.language);
64 div class="h-full first:rounded-l-full last:rounded-r-full"
65 style=(format!("width: {pct}%; background-color: {color};"))
66 title=(format!("{}: {pct:.1}%", l.language)) {}
67 }
68 }
69 div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-zinc-600" {
70 @for l in &langs {
71 @let pct = (l.percentage_bp as f32) / 100.0;
72 @let color = language_color(&l.language);
73 div class="flex items-center gap-1.5" {
74 span class="h-2 w-2 rounded-full shrink-0"
75 style=(format!("background-color: {color};")) {}
76 span class="font-medium text-zinc-800" { (l.language) }
77 span class="text-zinc-400 text-[11px]" { (format!("{pct:.1}%")) }
78 }
79 }
80 }
81 }
82 }
83 };
84 response_html(markup, 30)
85}
86
87fn language_color(lang: &str) -> &'static str {
88 match lang {
89 "Rust" => "#dea584",
90 "Go" => "#00add8",
91 "Python" => "#3572A5",
92 "JavaScript" => "#f1e05a",
93 "TypeScript" => "#3178c6",
94 "C" => "#555555",
95 "C++" => "#f34b7d",
96 "C#" => "#178600",
97 "Java" => "#b07219",
98 "PHP" => "#4F5D95",
99 "Ruby" => "#701516",
100 "Swift" => "#F05138",
101 "Kotlin" => "#A97BFF",
102 "HTML" => "#e34c26",
103 "CSS" | "SCSS" => "#563d7c",
104 "Shell" => "#89e051",
105 "SQL" => "#e38c00",
106 "Vue" => "#41b883",
107 "Lua" => "#000080",
108 "Dart" => "#00B4AB",
109 "Markdown" => "#083fa1",
110 "TOML" | "YAML" | "JSON" => "#cb171e",
111 _ => "#8b949e",
112 }
113}
Modified lib/git/src/handler/html/last_commit.rs lib/git/src/handler/html/last_commit.rs +10 −2 View lib/git/src/handler/html/last_commit.rs
@@ -16,19 +16,22 @@
1616pub struct LastCommitQuery {
1717 #[serde(default)]
1818 pub revision: Option<String>,
19 #[serde(default)]
20 pub path: Option<String>,
1921}
2022
2123#[utoipa::path(
2224 get,
2325 path = "/repos/{space_name}/{repo_name}/html/last-commit",
2426 tag = "html",
27 operation_id = "render_last_commit",
2528 params(HtmlRepoPath, LastCommitQuery),
2629 responses(
2730 (status = 200, description = "last commit html fragment", content_type = "text/html"),
2831 (status = 404, description = "repo not found")
2932 )
3033)]
31pub async fn render(
34pub async fn render_last_commit(
3235 State(service): State<GitService>,
3336 Path(path): Path<HtmlRepoPath>,
3437 Query(q): Query<LastCommitQuery>,
@@ -46,9 +49,14 @@
4649 if validate_revision(&revision).is_err() {
4750 return html_err(404, "ref invalid");
4851 }
52 let clean_path = q
53 .path
54 .as_deref()
55 .and_then(crate::handler::html::validate_path)
56 .unwrap_or_default();
4957 let rows = match ctx
5058 .service
51 .tree_entries_with_latest_html(&ctx.ctx, &revision)
59 .tree_with_commits_html(&ctx.ctx, &revision, &clean_path)
5260 .await
5361 {
5462 Ok(r) => r,
Modified lib/git/src/handler/html/mod.rs lib/git/src/handler/html/mod.rs +39 −11 View lib/git/src/handler/html/mod.rs
@@ -15,17 +15,25 @@
1515use crate::share::errors::GitError;
1616
1717pub mod badge;
18pub mod blame;
19pub mod blob_content;
1820pub mod blob_header;
1921pub mod breadcrumbs;
2022pub mod clone_buttons;
23pub mod commit_diff;
24pub mod commits;
2125pub mod error;
2226pub mod header;
27pub mod languages;
2328pub mod last_commit;
2429pub mod quickstart;
30pub mod readme;
2531pub mod refs_dropdown;
2632pub mod refs_summary;
33pub mod star_button;
2734pub mod tree;
2835pub mod tree_row;
36pub mod watch_button;
2937
3038pub use error::html_err;
3139
@@ -48,17 +56,25 @@
4856
4957pub fn router() -> Router<GitService> {
5058 Router::new()
51 .route("/clone-buttons", axum::routing::get(clone_buttons::render))
52 .route("/badge", axum::routing::get(badge::render))
53 .route("/quickstart", axum::routing::get(quickstart::render))
54 .route("/header", axum::routing::get(header::render))
55 .route("/last-commit", axum::routing::get(last_commit::render))
56 .route("/refs-summary", axum::routing::get(refs_summary::render))
57 .route("/refs-dropdown", axum::routing::get(refs_dropdown::render))
58 .route("/tree", axum::routing::get(tree::render))
59 .route("/tree-row", axum::routing::get(tree_row::render))
60 .route("/breadcrumbs", axum::routing::get(breadcrumbs::render))
61 .route("/blob-header", axum::routing::get(blob_header::render))
59 .route("/clone-buttons", axum::routing::get(clone_buttons::render_clone_buttons))
60 .route("/badge", axum::routing::get(badge::render_badge))
61 .route("/quickstart", axum::routing::get(quickstart::render_quickstart))
62 .route("/header", axum::routing::get(header::render_header))
63 .route("/last-commit", axum::routing::get(last_commit::render_last_commit))
64 .route("/refs-summary", axum::routing::get(refs_summary::render_refs_summary))
65 .route("/refs-dropdown", axum::routing::get(refs_dropdown::render_refs_dropdown))
66 .route("/tree", axum::routing::get(tree::render_tree))
67 .route("/tree-row", axum::routing::get(tree_row::render_tree_row))
68 .route("/breadcrumbs", axum::routing::get(breadcrumbs::render_breadcrumbs))
69 .route("/blob-header", axum::routing::get(blob_header::render_blob_header))
70 .route("/blob-content", axum::routing::get(blob_content::render_blob_content))
71 .route("/readme", axum::routing::get(readme::render_readme))
72 .route("/commits", axum::routing::get(commits::render_commits))
73 .route("/commit-diff", axum::routing::get(commit_diff::render_commit_diff))
74 .route("/blame", axum::routing::get(blame::render_blame))
75 .route("/languages", axum::routing::get(languages::render_languages))
76 .route("/star-button", axum::routing::get(star_button::render_star_button))
77 .route("/watch-button", axum::routing::get(watch_button::render_watch_button))
6278}
6379
6480#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
@@ -195,6 +211,18 @@
195211 t.format(&Rfc3339).unwrap_or_default()
196212}
197213
214pub fn unix_to_time(unix: i64) -> OffsetDateTime {
215 OffsetDateTime::from_unix_timestamp(unix).unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH)
216}
217
218pub fn relative_time_unix(unix: i64) -> String {
219 relative_time(unix_to_time(unix))
220}
221
222pub fn iso_time_unix(unix: i64) -> String {
223 iso_time(unix_to_time(unix))
224}
225
198226pub fn format_size(bytes: u64) -> String {
199227 if bytes < 1024 {
200228 return format!("{bytes} B");
Modified lib/git/src/handler/html/quickstart.rs lib/git/src/handler/html/quickstart.rs +2 −1 View lib/git/src/handler/html/quickstart.rs
@@ -12,13 +12,14 @@
1212 get,
1313 path = "/repos/{space_name}/{repo_name}/html/quickstart",
1414 tag = "html",
15 operation_id = "render_quickstart",
1516 params(HtmlRepoPath),
1617 responses(
1718 (status = 200, description = "empty repo quickstart html fragment", content_type = "text/html"),
1819 (status = 404, description = "repo not found")
1920 )
2021)]
21pub async fn render(
22pub async fn render_quickstart(
2223 State(service): State<GitService>,
2324 Path(path): Path<HtmlRepoPath>,
2425 auth: BasicAuth,
Added lib/git/src/handler/html/readme.rs lib/git/src/handler/html/readme.rs +142 −0 View lib/git/src/handler/html/readme.rs
@@ -0,0 +1,142 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
4use maud::{PreEscaped, html};
5
6use crate::handler::html::error::html_err;
7use crate::handler::html::{
8 HtmlRepoPath, archived_class, format_size, resolve, response_html, validate_path,
9};
10use crate::service::GitService;
11use crate::share::access::BasicAuth;
12use crate::share::validate::validate_revision;
13
14const README_CANDIDATES: &[&str] = &[
15 "README.md",
16 "readme.md",
17 "README.MD",
18 "Readme.md",
19 "README.markdown",
20 "readme.markdown",
21 "README.txt",
22 "readme.txt",
23 "README",
24 "readme",
25];
26
27#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
28pub struct ReadmeQuery {
29 #[serde(default)]
30 pub revision: Option<String>,
31 #[serde(default)]
32 pub path: Option<String>,
33}
34
35#[utoipa::path(
36 get,
37 path = "/repos/{space_name}/{repo_name}/html/readme",
38 tag = "html",
39 operation_id = "render_readme",
40 params(HtmlRepoPath, ReadmeQuery),
41 responses(
42 (status = 200, description = "rendered readme html fragment", content_type = "text/html"),
43 (status = 404, description = "repo, ref or readme not found")
44 )
45)]
46pub async fn render_readme(
47 State(service): State<GitService>,
48 Path(path): Path<HtmlRepoPath>,
49 Query(q): Query<ReadmeQuery>,
50 auth: BasicAuth,
51) -> Response {
52 let ctx = match resolve(service.clone(), path, auth).await {
53 Ok(c) => c,
54 Err(resp) => return resp,
55 };
56 let revision = q
57 .revision
58 .as_deref()
59 .unwrap_or(ctx.dto.default_branch.as_str())
60 .to_string();
61 if validate_revision(&revision).is_err() {
62 return html_err(404, "ref invalid");
63 }
64 let raw_path = q.path.unwrap_or_default();
65 let clean_path = match validate_path(&raw_path) {
66 Some(p) => p,
67 None => return html_err(404, "path invalid"),
68 };
69
70 let baby = service.baby(&ctx.ctx);
71 let dir_arg = if clean_path.is_empty() { None } else { Some(clean_path.as_str()) };
72 let entries = match baby.list_dir(&revision, dir_arg).await {
73 Ok(e) => e,
74 Err(_) => return html_err(404, "readme not found"),
75 };
76
77 let matched_entry = README_CANDIDATES.iter().find_map(|candidate| {
78 entries.iter().find(|e| {
79 (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec)
80 && e.name.eq_ignore_ascii_case(candidate)
81 })
82 });
83
84 let entry = match matched_entry {
85 Some(e) => e,
86 None => return html_err(404, "readme not found"),
87 };
88
89 let blob = match service
90 .blob_content(ctx.space_name.clone(), ctx.repo_name.clone(), entry.oid.to_string())
91 .await
92 {
93 Ok(b) => b,
94 Err(_) => return html_err(404, "failed to load readme blob"),
95 };
96
97 let text = String::from_utf8_lossy(&blob.data);
98 let is_markdown = entry.name.ends_with(".md")
99 || entry.name.ends_with(".MD")
100 || entry.name.ends_with(".markdown");
101
102 let rendered_content = if is_markdown {
103 let mut html_buf = String::with_capacity(text.len() * 3 / 2);
104 let parser = pulldown_cmark::Parser::new_ext(&text, pulldown_cmark::Options::all());
105 pulldown_cmark::html::push_html(&mut html_buf, parser);
106 PreEscaped(html_buf)
107 } else {
108 PreEscaped(format!(
109 "<pre class=\"overflow-x-auto p-4 text-xs font-mono text-zinc-800 whitespace-pre-wrap\">{}</pre>",
110 maud::html! { (text) }.into_string()
111 ))
112 };
113
114 let base = "git-readme rounded-lg border border-zinc-200 bg-white overflow-hidden text-sm";
115 let cls = archived_class(&ctx.dto, base);
116 let size_str = format_size(blob.size.max(0) as u64);
117
118 let markup = html! {
119 div class=(cls)
120 data-component="git-readme"
121 data-space=(ctx.space_name)
122 data-repo=(ctx.repo_name)
123 data-revision=(revision)
124 data-filename=(entry.name)
125 data-size=(blob.size)
126 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
127 div class="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2" {
128 div class="flex items-center gap-2" {
129 svg class="h-4 w-4 text-zinc-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
130 path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" {}
131 }
132 span class="font-mono text-xs font-semibold text-zinc-700" { (entry.name) }
133 }
134 span class="font-mono text-xs text-zinc-400" { (size_str) }
135 }
136 div class="markdown-body p-6 text-zinc-900 leading-relaxed max-w-none prose prose-zinc" {
137 (rendered_content)
138 }
139 }
140 };
141 response_html(markup, 30)
142}
Modified lib/git/src/handler/html/refs_dropdown.rs lib/git/src/handler/html/refs_dropdown.rs +65 −12 View lib/git/src/handler/html/refs_dropdown.rs
@@ -1,51 +1,104 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use axum::response::Response;
33use maud::html;
44
55use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
66use crate::service::GitService;
77use crate::share::access::BasicAuth;
8use crate::share::validate::validate_revision;
9
10#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
11pub struct RefsDropdownQuery {
12 #[serde(default)]
13 pub revision: Option<String>,
14}
815
916#[utoipa::path(
1017 get,
1118 path = "/repos/{space_name}/{repo_name}/html/refs-dropdown",
1219 tag = "html",
13 params(HtmlRepoPath),
20 operation_id = "render_refs_dropdown",
21 params(HtmlRepoPath, RefsDropdownQuery),
1422 responses(
1523 (status = 200, description = "refs dropdown html fragment", content_type = "text/html"),
1624 (status = 404, description = "repo not found")
1725 )
1826)]
19pub async fn render(
27pub async fn render_refs_dropdown(
2028 State(service): State<GitService>,
2129 Path(path): Path<HtmlRepoPath>,
30 Query(q): Query<RefsDropdownQuery>,
2231 auth: BasicAuth,
2332) -> Response {
24 let ctx = match resolve(service, path, auth).await {
33 let ctx = match resolve(service.clone(), path, auth).await {
2534 Ok(c) => c,
2635 Err(resp) => return resp,
2736 };
28 let base = "git-refs-dropdown relative inline-flex w-full max-w-xs";
37 let current_rev = q
38 .revision
39 .as_deref()
40 .filter(|r| validate_revision(r).is_ok())
41 .unwrap_or(ctx.dto.default_branch.as_str());
42
43 let branches = service
44 .list_branches(ctx.space_name.clone(), ctx.repo_name.clone())
45 .await
46 .unwrap_or_default();
47 let tags = service
48 .list_tags(ctx.space_name.clone(), ctx.repo_name.clone())
49 .await
50 .unwrap_or_default();
51
52 let base = "git-refs-dropdown relative inline-flex w-full max-w-xs flex-col";
2953 let cls = archived_class(&ctx.dto, base);
3054 let default = ctx.dto.default_branch.clone();
3155 let space = ctx.space_name.clone();
3256 let repo = ctx.repo_name.clone();
57
58 let is_custom_rev = !branches.iter().any(|b| b.name == current_rev)
59 && !tags.iter().any(|t| t.name == current_rev)
60 && current_rev != default;
61
3362 let markup = html! {
3463 div class=(cls)
3564 data-component="git-refs-dropdown"
3665 data-space=(space)
3766 data-repo=(repo)
3867 data-default-branch=(default)
68 data-current-revision=(current_rev)
3969 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
40 label class="mb-1 block text-xs font-medium text-zinc-600" { "Branch" }
41 select class="w-full rounded border border-zinc-200 bg-white px-2 py-1 text-sm"
70 label class="mb-1 block text-xs font-medium text-zinc-600" { "Switch ref" }
71 select class="w-full rounded border border-zinc-200 bg-white px-2 py-1 text-sm text-zinc-800"
4272 data-action="switch-ref" {
43 option value=(ctx.dto.default_branch) selected[true] {
44 (ctx.dto.default_branch) " (default)"
73 @if is_custom_rev {
74 option value=(current_rev) selected[true] {
75 "Tree: " (current_rev)
76 }
77 }
78 @if !branches.is_empty() {
79 optgroup label="Branches" {
80 @for b in &branches {
81 @let is_sel = b.name == current_rev;
82 @let is_def = b.name == default;
83 option value=(b.name) selected[is_sel] {
84 (b.name) (if is_def { " (default)" } else { "" })
85 }
86 }
87 }
88 } @else {
89 option value=(default) selected[true] {
90 (default) " (default)"
91 }
4592 }
46 @if ctx.dto.nums_branches > 1 {
47 option disabled="true" value="" { "—" }
48 option value="__tags__" disabled="true" { "tags: " (ctx.dto.nums_tags) }
93 @if !tags.is_empty() {
94 optgroup label="Tags" {
95 @for t in &tags {
96 @let is_sel = t.name == current_rev;
97 option value=(t.name) selected[is_sel] {
98 (t.name)
99 }
100 }
101 }
49102 }
50103 }
51104 p class="mt-1 text-xs text-zinc-500" {
Modified lib/git/src/handler/html/refs_summary.rs lib/git/src/handler/html/refs_summary.rs +2 −1 View lib/git/src/handler/html/refs_summary.rs
@@ -10,13 +10,14 @@
1010 get,
1111 path = "/repos/{space_name}/{repo_name}/html/refs-summary",
1212 tag = "html",
13 operation_id = "render_refs_summary",
1314 params(HtmlRepoPath),
1415 responses(
1516 (status = 200, description = "refs summary html fragment", content_type = "text/html"),
1617 (status = 404, description = "repo not found")
1718 )
1819)]
19pub async fn render(
20pub async fn render_refs_summary(
2021 State(service): State<GitService>,
2122 Path(path): Path<HtmlRepoPath>,
2223 auth: BasicAuth,
Added lib/git/src/handler/html/star_button.rs lib/git/src/handler/html/star_button.rs +75 −0 View lib/git/src/handler/html/star_button.rs
@@ -0,0 +1,75 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/star-button",
12 tag = "html",
13 operation_id = "render_star_button",
14 params(HtmlRepoPath),
15 responses(
16 (status = 200, description = "star button html fragment", content_type = "text/html"),
17 (status = 404, description = "repo not found")
18 )
19)]
20pub async fn render_star_button(
21 State(service): State<GitService>,
22 Path(path): Path<HtmlRepoPath>,
23 auth: BasicAuth,
24) -> Response {
25 let ctx = match resolve(service.clone(), path, auth).await {
26 Ok(c) => c,
27 Err(resp) => return resp,
28 };
29
30 let is_starred = match ctx.uid {
31 Some(uid) => service
32 .is_starred(&ctx.ctx, uid)
33 .await
34 .unwrap_or(false),
35 None => false,
36 };
37
38 let base = "git-star-button inline-flex items-center rounded-md border border-zinc-200 bg-white text-xs text-zinc-700 shadow-sm";
39 let cls = archived_class(&ctx.dto, base);
40 let space = ctx.space_name.clone();
41 let repo = ctx.repo_name.clone();
42 let star_count = ctx.dto.nums_star;
43
44 let markup = html! {
45 div class=(cls)
46 data-component="git-star-button"
47 data-space=(space)
48 data-repo=(repo)
49 data-starred=(if is_starred { "true" } else { "false" })
50 data-count=(star_count)
51 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
52 button class="flex items-center gap-1.5 px-2.5 py-1 font-medium hover:bg-zinc-50 border-r border-zinc-200 transition-colors"
53 type="button"
54 data-action="toggle-star" {
55 @if is_starred {
56 svg class="h-3.5 w-3.5 text-amber-500 fill-amber-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
57 path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z" {}
58 }
59 span { "Starred" }
60 } @else {
61 svg class="h-3.5 w-3.5 text-zinc-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
62 path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z" {}
63 }
64 span { "Star" }
65 }
66 }
67 a class="px-2.5 py-1 font-mono text-[11px] text-zinc-600 hover:text-zinc-900 hover:bg-zinc-50 transition-colors"
68 href=(format!("/{space}/{repo}/stargazers"))
69 title=(format!("{star_count} stargazers")) {
70 (star_count)
71 }
72 }
73 };
74 response_html(markup, 30)
75}
Modified lib/git/src/handler/html/tree.rs lib/git/src/handler/html/tree.rs +24 −1 View lib/git/src/handler/html/tree.rs
@@ -28,13 +28,14 @@
2828 get,
2929 path = "/repos/{space_name}/{repo_name}/html/tree",
3030 tag = "html",
31 operation_id = "render_tree",
3132 params(HtmlRepoPath, TreeQuery),
3233 responses(
3334 (status = 200, description = "tree list html fragment", content_type = "text/html"),
3435 (status = 404, description = "repo, ref or path not found")
3536 )
3637)]
37pub async fn render(
38pub async fn render_tree(
3839 State(service): State<GitService>,
3940 Path(path): Path<HtmlRepoPath>,
4041 Query(q): Query<TreeQuery>,
@@ -64,6 +65,9 @@
6465 .await
6566 {
6667 Ok(r) => r,
68 Err(crate::share::errors::GitError::NotFound) => {
69 return html_err(404, "ref not found");
70 }
6771 Err(e) => {
6872 tracing::warn!(error = %e, "tree internal");
6973 return html_err(500, "internal");
@@ -102,6 +106,25 @@
102106 }
103107 }
104108 tbody {
109 @if !clean_path.is_empty() {
110 @let parent_dir = if clean_path.contains('/') {
111 clean_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("")
112 } else {
113 ""
114 };
115 @let parent_href = if parent_dir.is_empty() {
116 format!("/{space}/{repo}")
117 } else {
118 format!("/{space}/{repo}/tree/{revision}/{parent_dir}/")
119 };
120 tr class="border-b border-zinc-100 bg-zinc-50/50 hover:bg-zinc-50" {
121 td class="px-2 py-1 font-mono text-xs text-zinc-500" colspan="3" {
122 a class="text-zinc-700 hover:underline flex items-center gap-1 font-semibold" href=(parent_href) {
123 span { ".." }
124 }
125 }
126 }
127 }
105128 @if entries.is_empty() {
106129 tr {
107130 td class="px-2 py-3 text-center text-xs text-zinc-500"
Modified lib/git/src/handler/html/tree_row.rs lib/git/src/handler/html/tree_row.rs +5 −1 View lib/git/src/handler/html/tree_row.rs
@@ -25,13 +25,14 @@
2525 get,
2626 path = "/repos/{space_name}/{repo_name}/html/tree-row",
2727 tag = "html",
28 operation_id = "render_tree_row",
2829 params(HtmlRepoPath, TreeRowQuery),
2930 responses(
3031 (status = 200, description = "tree row html fragment", content_type = "text/html"),
3132 (status = 404, description = "repo, ref or path not found")
3233 )
3334)]
34pub async fn render(
35pub async fn render_tree_row(
3536 State(service): State<GitService>,
3637 Path(path): Path<HtmlRepoPath>,
3738 Query(q): Query<TreeRowQuery>,
@@ -77,6 +78,9 @@
7778 .await
7879 {
7980 Ok(r) => r,
81 Err(crate::share::errors::GitError::NotFound) => {
82 return html_err(404, "ref not found");
83 }
8084 Err(e) => {
8185 tracing::warn!(error = %e, "tree-row internal");
8286 return html_err(500, "internal");
Added lib/git/src/handler/html/watch_button.rs lib/git/src/handler/html/watch_button.rs +81 −0 View lib/git/src/handler/html/watch_button.rs
@@ -0,0 +1,81 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4use models::common::watch_mode::WatchMode;
5
6use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
7use crate::service::GitService;
8use crate::share::access::BasicAuth;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/html/watch-button",
13 tag = "html",
14 operation_id = "render_watch_button",
15 params(HtmlRepoPath),
16 responses(
17 (status = 200, description = "watch button html fragment", content_type = "text/html"),
18 (status = 404, description = "repo not found")
19 )
20)]
21pub async fn render_watch_button(
22 State(service): State<GitService>,
23 Path(path): Path<HtmlRepoPath>,
24 auth: BasicAuth,
25) -> Response {
26 let ctx = match resolve(service.clone(), path, auth).await {
27 Ok(c) => c,
28 Err(resp) => return resp,
29 };
30
31 let watch_mode = match ctx.uid {
32 Some(uid) => service
33 .get_watch_mode(&ctx.ctx, uid)
34 .await
35 .unwrap_or_default()
36 .unwrap_or(WatchMode::Normal),
37 None => WatchMode::Normal,
38 };
39
40 let is_watching = matches!(watch_mode, WatchMode::Watch | WatchMode::Normal);
41
42 let base = "git-watch-button inline-flex items-center rounded-md border border-zinc-200 bg-white text-xs text-zinc-700 shadow-sm";
43 let cls = archived_class(&ctx.dto, base);
44 let space = ctx.space_name.clone();
45 let repo = ctx.repo_name.clone();
46 let watch_count = ctx.dto.nums_watch;
47
48 let mode_text = match watch_mode {
49 WatchMode::Watch => "Watching",
50 WatchMode::Normal => "Normal",
51 WatchMode::NoWatch => "Not watching",
52 WatchMode::Ignore => "Ignoring",
53 };
54
55 let markup = html! {
56 div class=(cls)
57 data-component="git-watch-button"
58 data-space=(space)
59 data-repo=(repo)
60 data-watching=(if is_watching { "true" } else { "false" })
61 data-mode=(format!("{watch_mode:?}").to_lowercase())
62 data-count=(watch_count)
63 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
64 button class="flex items-center gap-1.5 px-2.5 py-1 font-medium hover:bg-zinc-50 border-r border-zinc-200 transition-colors"
65 type="button"
66 data-action="change-watch" {
67 svg class="h-3.5 w-3.5 text-zinc-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
68 path d="M8 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" {}
69 path d="M1.5 8s2.5-5.5 6.5-5.5S14.5 8 14.5 8s-2.5 5.5-6.5 5.5S1.5 8 1.5 8zM8 12c2.5 0 4.5-2.5 4.5-4S10.5 4 8 4 3.5 6.5 3.5 8s2 4 4.5 4z" {}
70 }
71 span { (mode_text) }
72 }
73 a class="px-2.5 py-1 font-mono text-[11px] text-zinc-600 hover:text-zinc-900 hover:bg-zinc-50 transition-colors"
74 href=(format!("/{space}/{repo}/watchers"))
75 title=(format!("{watch_count} watchers")) {
76 (watch_count)
77 }
78 }
79 };
80 response_html(markup, 30)
81}
Modified lib/git/src/openapi.rs lib/git/src/openapi.rs +23 −12 View lib/git/src/openapi.rs
@@ -43,7 +43,6 @@
4343 handler::content::commit_object::commit_object,
4444 handler::content::write_blob::write_blob,
4545 handler::content::write_tree_from_files::write_tree_from_files,
46 handler::diffs::files::diff_files,
4746 handler::diffs::files_changed_between::files_changed_between,
4847 handler::diffs::for_commit::diff_for_commit,
4948 handler::diffs::range_diff::range_diff,
@@ -131,17 +130,25 @@
131130 handler::whitelists::list_tag_whitelists,
132131 handler::whitelists::remove_branch_whitelist,
133132 handler::whitelists::remove_tag_whitelist,
134 handler::html::clone_buttons::render,
135 handler::html::badge::render,
136 handler::html::quickstart::render,
137 handler::html::header::render,
138 handler::html::last_commit::render,
139 handler::html::refs_summary::render,
140 handler::html::refs_dropdown::render,
141 handler::html::tree::render,
142 handler::html::tree_row::render,
143 handler::html::breadcrumbs::render,
144 handler::html::blob_header::render,
133 handler::html::clone_buttons::render_clone_buttons,
134 handler::html::badge::render_badge,
135 handler::html::quickstart::render_quickstart,
136 handler::html::header::render_header,
137 handler::html::last_commit::render_last_commit,
138 handler::html::refs_summary::render_refs_summary,
139 handler::html::refs_dropdown::render_refs_dropdown,
140 handler::html::tree::render_tree,
141 handler::html::tree_row::render_tree_row,
142 handler::html::breadcrumbs::render_breadcrumbs,
143 handler::html::blob_header::render_blob_header,
144 handler::html::blob_content::render_blob_content,
145 handler::html::readme::render_readme,
146 handler::html::commits::render_commits,
147 handler::html::commit_diff::render_commit_diff,
148 handler::html::blame::render_blame,
149 handler::html::languages::render_languages,
150 handler::html::star_button::render_star_button,
151 handler::html::watch_button::render_watch_button,
145152 ),
146153 components(schemas(
147154 export::rest::NoData,
@@ -165,8 +172,10 @@
165172 crate::service::blobs::RawDownloadDto,
166173 crate::service::branches::BranchDto,
167174 crate::service::branches::CreateBranchCmd,
175 crate::service::commits::CommitDto,
168176 crate::service::commits::CommitStatusDto,
169177 crate::service::commits::CommitStatusDtoState,
178 crate::service::commits::CommitsPageDto,
170179 crate::service::commits::SetCommitStatusCmd,
171180 crate::service::conflicts::ConflictDetailDto,
172181 crate::service::conflicts::MergeStatusDto,
@@ -174,6 +183,8 @@
174183 crate::service::content::ObjectKind,
175184 crate::service::content::SignatureDto,
176185 crate::service::content::WriteObjectResultDto,
186 crate::service::diffs::ChangedPathDto,
187 crate::service::diffs::ChangedPathDiffDto,
177188 crate::service::diffs::DiffTreeRecordDto,
178189 crate::service::hooks::ScriptOutputDto,
179190 crate::service::licenses::LicenseDto,
Modified lib/git/src/routes.rs lib/git/src/routes.rs +3 −48 View lib/git/src/routes.rs
@@ -33,7 +33,6 @@
3333use crate::handler::content::commit_object::commit_object;
3434use crate::handler::content::write_blob::write_blob;
3535use crate::handler::content::write_tree_from_files::write_tree_from_files;
36use crate::handler::diffs::files::diff_files;
3736use crate::handler::diffs::files_changed_between::files_changed_between;
3837use crate::handler::diffs::for_commit::diff_for_commit;
3938use crate::handler::diffs::range_diff::range_diff;
@@ -277,10 +276,6 @@
277276 get(diff_stats),
278277 )
279278 .route(
280 "/{space_name}/{repo_name}/diff/files-with-context",
281 get(diff_files),
282 )
283 .route(
284279 "/{space_name}/{repo_name}/diff/for-commit/{commit}",
285280 get(diff_for_commit),
286281 )
@@ -465,49 +460,9 @@
465460 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
466461 delete(remove_tag_whitelist),
467462 )
468 .route(
469 "/{space_name}/{repo_name}/html/clone-buttons",
470 get(crate::handler::html::clone_buttons::render),
471 )
472 .route(
473 "/{space_name}/{repo_name}/html/badge",
474 get(crate::handler::html::badge::render),
475 )
476 .route(
477 "/{space_name}/{repo_name}/html/quickstart",
478 get(crate::handler::html::quickstart::render),
479 )
480 .route(
481 "/{space_name}/{repo_name}/html/header",
482 get(crate::handler::html::header::render),
483 )
484 .route(
485 "/{space_name}/{repo_name}/html/last-commit",
486 get(crate::handler::html::last_commit::render),
487 )
488 .route(
489 "/{space_name}/{repo_name}/html/refs-summary",
490 get(crate::handler::html::refs_summary::render),
491 )
492 .route(
493 "/{space_name}/{repo_name}/html/refs-dropdown",
494 get(crate::handler::html::refs_dropdown::render),
495 )
496 .route(
497 "/{space_name}/{repo_name}/html/tree",
498 get(crate::handler::html::tree::render),
499 )
500 .route(
501 "/{space_name}/{repo_name}/html/tree-row",
502 get(crate::handler::html::tree_row::render),
503 )
504 .route(
505 "/{space_name}/{repo_name}/html/breadcrumbs",
506 get(crate::handler::html::breadcrumbs::render),
507 )
508 .route(
509 "/{space_name}/{repo_name}/html/blob-header",
510 get(crate::handler::html::blob_header::render),
463 .nest(
464 "/{space_name}/{repo_name}/html",
465 crate::handler::html::router(),
511466 )
512467}
513468
Modified lib/git/Cargo.toml lib/git/Cargo.toml +2 −1 View lib/git/Cargo.toml
@@ -35,4 +35,5 @@
3535bytes = { workspace = true }
3636session = { workspace = true }
3737utoipa = { workspace = true }
38maud = { workspace = true }
No newline at end of file
38maud = { workspace = true }
39pulldown-cmark = { workspace = true }
No newline at end of file
Modified Cargo.lock Cargo.lock +3 −1 View Cargo.lock
@@ -2578,6 +2578,7 @@
25782578 "http-body-util",
25792579 "maud",
25802580 "models",
2581 "pulldown-cmark",
25812582 "rand 0.8.7",
25822583 "redis",
25832584 "russh",
@@ -2599,7 +2600,7 @@
25992600[[package]]
26002601name = "gitbaby"
26012602version = "0.1.0"
2602source = "git+https://gitea.gitdata.me/GitDataAi/GitBaby.git?rev=41154f520b#41154f520b62f52f3ea36b836e56236e811e613e"
2603source = "git+https://gitea.gitdata.me/GitDataAi/GitBaby.git?rev=69bd99a#69bd99a23d6302be9acfb353e8c680472e259e69"
26032604dependencies = [
26042605 "async-trait",
26052606 "foyer",
@@ -7664,6 +7665,7 @@
76647665 "http 0.2.12",
76657666 "rust-s3-async",
76667667 "tokio",
7668 "tracing",
76677669]
76687670
76697671[[package]]