a73x

db21d0f6

Show repo descriptions and lay out web diffs side by side

a73x   2026-04-04 10:50

Commit message
Show repo descriptions and lay out web diffs side by side

.description
Old New
@@ -0,0 +1 @@
1 A patch based git workflow
src/server/http/repo/diff.rs
Old New
@@ -1,17 +1,39 @@
1 use std::cell::RefCell;
2 use std::path::Path;
1 use std::sync::Arc; 3 use std::sync::Arc;
2 4
3 use axum::extract::{Path, State}; 5 use axum::extract::{Path as AxumPath, State};
4 use axum::response::{IntoResponse, Response}; 6 use axum::response::{IntoResponse, Response};
5 use chrono::{TimeZone, Utc}; 7 use chrono::{TimeZone, Utc};
8 use git2::Delta;
6 9
7 use super::{AppState, collab_counts, not_found, open_repo}; 10 use super::{collab_counts, not_found, open_repo, AppState};
8 11
9 #[derive(Debug)] 12 #[derive(Debug)]
10 pub struct DiffLine { 13 pub struct DiffCell {
11 pub kind: String, 14 pub kind: String,
15 pub line_no: String,
12 pub text: String, 16 pub text: String,
13 } 17 }
14 18
19 #[derive(Debug)]
20 pub struct DiffRow {
21 pub left: DiffCell,
22 pub right: DiffCell,
23 }
24
25 #[derive(Debug)]
26 pub struct DiffHunk {
27 pub header: String,
28 pub rows: Vec<DiffRow>,
29 }
30
31 #[derive(Debug)]
32 pub struct DiffFile {
33 pub path: String,
34 pub hunks: Vec<DiffHunk>,
35 }
36
15 #[derive(askama::Template, askama_web::WebTemplate)] 37 #[derive(askama::Template, askama_web::WebTemplate)]
16 #[template(path = "diff.html")] 38 #[template(path = "diff.html")]
17 pub struct DiffTemplate { 39 pub struct DiffTemplate {
@@ -22,13 +44,203 @@ pub struct DiffTemplate {
22 pub open_issues: usize, 44 pub open_issues: usize,
23 pub short_id: String, 45 pub short_id: String,
24 pub summary: String, 46 pub summary: String,
25 pub body: String, 47 pub full_message: String,
26 pub author: String, 48 pub author: String,
27 pub date: String, 49 pub date: String,
28 pub diff_lines: Vec<DiffLine>, 50 pub diff_files: Vec<DiffFile>,
51 }
52
53 fn empty_cell() -> DiffCell {
54 DiffCell {
55 kind: "empty".to_string(),
56 line_no: String::new(),
57 text: String::new(),
58 }
59 }
60
61 fn format_line_no(line_no: Option<u32>) -> String {
62 line_no.map(|value| value.to_string()).unwrap_or_default()
63 }
64
65 fn sanitize_line_text(text: &[u8]) -> String {
66 String::from_utf8_lossy(text)
67 .trim_end_matches(['\r', '\n'])
68 .to_string()
69 }
70
71 fn flush_pending_deletions(rows: &mut Vec<DiffRow>, pending_deletions: &mut Vec<DiffCell>) {
72 for deletion in pending_deletions.drain(..) {
73 rows.push(DiffRow {
74 left: deletion,
75 right: empty_cell(),
76 });
77 }
29 } 78 }
30 79
31 fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { 80 #[derive(Debug, Default)]
81 struct DiffCollector {
82 files: Vec<DiffFile>,
83 current_file: Option<DiffFile>,
84 current_hunk: Option<DiffHunk>,
85 pending_deletions: Vec<DiffCell>,
86 }
87
88 impl DiffCollector {
89 fn ensure_current_file(&mut self, delta: git2::DiffDelta<'_>) {
90 if self.current_file.is_none() {
91 self.current_file = Some(DiffFile {
92 path: format_delta_path(delta),
93 hunks: Vec::new(),
94 });
95 }
96 }
97
98 fn finish_hunk(&mut self) {
99 if let Some(hunk) = self.current_hunk.as_mut() {
100 flush_pending_deletions(&mut hunk.rows, &mut self.pending_deletions);
101 }
102
103 if let Some(hunk) = self.current_hunk.take() {
104 if let Some(file) = self.current_file.as_mut() {
105 file.hunks.push(hunk);
106 }
107 }
108 }
109
110 fn finish_file(&mut self) {
111 self.finish_hunk();
112 if let Some(file) = self.current_file.take() {
113 self.files.push(file);
114 }
115 }
116
117 fn start_file(&mut self, delta: git2::DiffDelta<'_>) {
118 self.finish_file();
119 self.current_file = Some(DiffFile {
120 path: format_delta_path(delta),
121 hunks: Vec::new(),
122 });
123 }
124
125 fn push_binary_marker(&mut self, delta: git2::DiffDelta<'_>) {
126 self.ensure_current_file(delta);
127 self.finish_hunk();
128 if let Some(file) = self.current_file.as_mut() {
129 file.hunks.push(DiffHunk {
130 header: "Binary file".to_string(),
131 rows: vec![DiffRow {
132 left: DiffCell {
133 kind: "ctx".to_string(),
134 line_no: String::new(),
135 text: "Binary files differ".to_string(),
136 },
137 right: empty_cell(),
138 }],
139 });
140 }
141 }
142
143 fn start_hunk(&mut self, delta: git2::DiffDelta<'_>, hunk: git2::DiffHunk<'_>) {
144 self.ensure_current_file(delta);
145 self.finish_hunk();
146 self.current_hunk = Some(DiffHunk {
147 header: String::from_utf8_lossy(hunk.header())
148 .trim_end_matches(['\r', '\n'])
149 .to_string(),
150 rows: Vec::new(),
151 });
152 }
153
154 fn push_line(&mut self, line: git2::DiffLine<'_>) {
155 let Some(hunk) = self.current_hunk.as_mut() else {
156 return;
157 };
158
159 match line.origin() {
160 '-' => self.pending_deletions.push(DiffCell {
161 kind: "del".to_string(),
162 line_no: format_line_no(line.old_lineno()),
163 text: sanitize_line_text(line.content()),
164 }),
165 '+' => {
166 let addition = DiffCell {
167 kind: "add".to_string(),
168 line_no: format_line_no(line.new_lineno()),
169 text: sanitize_line_text(line.content()),
170 };
171 if self.pending_deletions.is_empty() {
172 hunk.rows.push(DiffRow {
173 left: empty_cell(),
174 right: addition,
175 });
176 } else {
177 let deletion = self.pending_deletions.remove(0);
178 hunk.rows.push(DiffRow {
179 left: deletion,
180 right: addition,
181 });
182 }
183 }
184 _ => {
185 flush_pending_deletions(&mut hunk.rows, &mut self.pending_deletions);
186 let text = sanitize_line_text(line.content());
187 hunk.rows.push(DiffRow {
188 left: DiffCell {
189 kind: "ctx".to_string(),
190 line_no: format_line_no(line.old_lineno()),
191 text: text.clone(),
192 },
193 right: DiffCell {
194 kind: "ctx".to_string(),
195 line_no: format_line_no(line.new_lineno()),
196 text,
197 },
198 });
199 }
200 }
201 }
202
203 fn into_files(mut self) -> Vec<DiffFile> {
204 self.finish_file();
205 self.files
206 }
207 }
208
209 fn format_delta_path(delta: git2::DiffDelta<'_>) -> String {
210 let old_path = delta.old_file().path();
211 let new_path = delta.new_file().path();
212
213 match delta.status() {
214 Delta::Added => new_path
215 .or(old_path)
216 .map(path_to_string)
217 .unwrap_or_else(|| "(new file)".to_string()),
218 Delta::Deleted => old_path
219 .or(new_path)
220 .map(path_to_string)
221 .unwrap_or_else(|| "(deleted file)".to_string()),
222 Delta::Renamed => match (old_path, new_path) {
223 (Some(old_path), Some(new_path)) => {
224 format!(
225 "{} → {}",
226 path_to_string(old_path),
227 path_to_string(new_path)
228 )
229 }
230 _ => "(renamed file)".to_string(),
231 },
232 _ => new_path
233 .or(old_path)
234 .map(path_to_string)
235 .unwrap_or_else(|| "(unknown path)".to_string()),
236 }
237 }
238
239 fn path_to_string(path: &Path) -> String {
240 path.to_string_lossy().into_owned()
241 }
242
243 fn compute_diff_files(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffFile> {
32 let commit = match repo.find_commit(oid) { 244 let commit = match repo.find_commit(oid) {
33 Ok(c) => c, 245 Ok(c) => c,
34 Err(_) => return Vec::new(), 246 Err(_) => return Vec::new(),
@@ -39,37 +251,54 @@ fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine>
39 Err(_) => return Vec::new(), 251 Err(_) => return Vec::new(),
40 }; 252 };
41 253
42 let old_tree = commit 254 let old_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok());
43 .parent(0)
44 .ok()
45 .and_then(|p| p.tree().ok());
46 255
47 let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { 256 let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) {
48 Ok(d) => d, 257 Ok(d) => d,
49 Err(_) => return Vec::new(), 258 Err(_) => return Vec::new(),
50 }; 259 };
51 260
52 let mut lines: Vec<DiffLine> = Vec::new(); 261 let collector = RefCell::new(DiffCollector::default());
53 262
54 let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { 263 let mut file_cb = |delta: git2::DiffDelta<'_>, _progress: f32| {
55 let text = String::from_utf8_lossy(line.content()).into_owned(); 264 collector.borrow_mut().start_file(delta);
56 let kind = match line.origin() { 265 true
57 '+' => "add", 266 };
58 '-' => "del", 267
59 '@' => "hunk", 268 let mut binary_cb = |delta: git2::DiffDelta<'_>, _binary: git2::DiffBinary<'_>| {
60 'F' => "file", 269 collector.borrow_mut().push_binary_marker(delta);
61 _ => "ctx", 270 true
62 } 271 };
63 .to_string(); 272
64 lines.push(DiffLine { kind, text }); 273 let mut hunk_cb = |delta: git2::DiffDelta<'_>, hunk: git2::DiffHunk<'_>| {
274 collector.borrow_mut().start_hunk(delta, hunk);
65 true 275 true
66 }); 276 };
277
278 let mut line_cb = |_delta: git2::DiffDelta<'_>,
279 _hunk: Option<git2::DiffHunk<'_>>,
280 line: git2::DiffLine<'_>| {
281 collector.borrow_mut().push_line(line);
282 true
283 };
67 284
68 lines 285 if diff
286 .foreach(
287 &mut file_cb,
288 Some(&mut binary_cb),
289 Some(&mut hunk_cb),
290 Some(&mut line_cb),
291 )
292 .is_err()
293 {
294 return Vec::new();
295 }
296
297 collector.into_inner().into_files()
69 } 298 }
70 299
71 pub async fn diff( 300 pub async fn diff(
72 Path((repo_name, oid_str)): Path<(String, String)>, 301 AxumPath((repo_name, oid_str)): AxumPath<(String, String)>,
73 State(state): State<Arc<AppState>>, 302 State(state): State<Arc<AppState>>,
74 ) -> Response { 303 ) -> Response {
75 let (_entry, repo) = match open_repo(&state, &repo_name) { 304 let (_entry, repo) = match open_repo(&state, &repo_name) {
@@ -100,15 +329,8 @@ pub async fn diff(
100 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) 329 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
101 .unwrap_or_default(); 330 .unwrap_or_default();
102 331
103 let body = commit 332 let full_message = commit.message().unwrap_or("").trim_end().to_string();
104 .message() 333 let diff_files = compute_diff_files(&repo, oid);
105 .map(|m| {
106 let lines: Vec<&str> = m.splitn(2, '\n').collect();
107 if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() }
108 })
109 .unwrap_or_default();
110
111 let diff_lines = compute_diff_lines(&repo, oid);
112 334
113 DiffTemplate { 335 DiffTemplate {
114 site_title: state.site_title.clone(), 336 site_title: state.site_title.clone(),
@@ -118,10 +340,10 @@ pub async fn diff(
118 open_issues, 340 open_issues,
119 short_id, 341 short_id,
120 summary, 342 summary,
121 body, 343 full_message,
122 author, 344 author,
123 date, 345 date,
124 diff_lines, 346 diff_files,
125 } 347 }
126 .into_response() 348 .into_response()
127 } 349 }
src/server/http/repo_list.rs
Old New
@@ -35,8 +35,9 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> {
35 entries 35 entries
36 .into_iter() 36 .into_iter()
37 .map(|entry| { 37 .map(|entry| {
38 let description = read_description(&entry); 38 let repo = crate::repos::open(&entry).ok();
39 let last_commit = read_last_commit(&entry); 39 let description = resolve_description(&entry, repo.as_ref());
40 let last_commit = repo.as_ref().map(read_last_commit).unwrap_or_default();
40 RepoListItem { 41 RepoListItem {
41 name: entry.name, 42 name: entry.name,
42 description, 43 description,
@@ -46,34 +47,68 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> {
46 .collect() 47 .collect()
47 } 48 }
48 49
49 fn read_description(entry: &crate::repos::RepoEntry) -> String { 50 fn normalize_description(description: &str) -> Option<String> {
51 let description = description.trim();
52 if description.is_empty() || description.starts_with("Unnamed repository") {
53 None
54 } else {
55 Some(description.to_string())
56 }
57 }
58
59 fn resolve_display_commit(repo: &git2::Repository) -> Option<git2::Commit<'_>> {
60 if let Ok(head) = repo.head() {
61 if let Ok(commit) = head.peel_to_commit() {
62 return Some(commit);
63 }
64 }
65
66 let branches = repo.branches(Some(git2::BranchType::Local)).ok()?;
67 for branch in branches.filter_map(Result::ok) {
68 let (branch, _) = branch;
69 if let Ok(commit) = branch.into_reference().peel_to_commit() {
70 return Some(commit);
71 }
72 }
73
74 None
75 }
76
77 fn read_tracked_description(repo: &git2::Repository) -> Option<String> {
78 let commit = resolve_display_commit(repo)?;
79 let tree = commit.tree().ok()?;
80 let entry = tree.get_path(std::path::Path::new(".description")).ok()?;
81 let blob = entry.to_object(repo).ok()?.into_blob().ok()?;
82 let description = std::str::from_utf8(blob.content()).ok()?;
83 normalize_description(description)
84 }
85
86 fn read_local_description(entry: &crate::repos::RepoEntry) -> Option<String> {
50 let desc_path = if entry.bare { 87 let desc_path = if entry.bare {
51 entry.path.join("description") 88 entry.path.join("description")
52 } else { 89 } else {
53 entry.path.join(".git").join("description") 90 entry.path.join(".git").join("description")
54 }; 91 };
55 92
56 std::fs::read_to_string(&desc_path) 93 let description = std::fs::read_to_string(&desc_path).ok()?;
57 .ok() 94 normalize_description(&description)
58 .map(|s| s.trim().to_string())
59 .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository"))
60 .unwrap_or_default()
61 } 95 }
62 96
63 fn read_last_commit(entry: &crate::repos::RepoEntry) -> String { 97 fn resolve_description(entry: &crate::repos::RepoEntry, repo: Option<&git2::Repository>) -> String {
64 let repo = match crate::repos::open(entry) { 98 repo.and_then(read_tracked_description)
65 Ok(r) => r, 99 .or_else(|| read_local_description(entry))
66 Err(_) => return String::new(), 100 .unwrap_or_default()
67 }; 101 }
68 102
69 let head = match repo.head() { 103 fn read_description(entry: &crate::repos::RepoEntry) -> String {
70 Ok(h) => h, 104 let repo = crate::repos::open(entry).ok();
71 Err(_) => return String::new(), 105 resolve_description(entry, repo.as_ref())
72 }; 106 }
73 107
74 let commit = match head.peel_to_commit() { 108 fn read_last_commit(repo: &git2::Repository) -> String {
75 Ok(c) => c, 109 let commit = match resolve_display_commit(repo) {
76 Err(_) => return String::new(), 110 Some(commit) => commit,
111 None => return String::new(),
77 }; 112 };
78 113
79 let time = commit.time(); 114 let time = commit.time();
@@ -85,3 +120,77 @@ fn read_last_commit(entry: &crate::repos::RepoEntry) -> String {
85 .map(|dt| dt.format("%Y-%m-%d").to_string()) 120 .map(|dt| dt.format("%Y-%m-%d").to_string())
86 .unwrap_or_default() 121 .unwrap_or_default()
87 } 122 }
123
124 #[cfg(test)]
125 mod tests {
126 use super::{read_description, read_local_description};
127 use std::path::Path;
128 use std::process::Command;
129 use tempfile::TempDir;
130
131 fn git(cwd: &Path, args: &[&str]) {
132 let output = Command::new("git")
133 .args(args)
134 .current_dir(cwd)
135 .output()
136 .expect("git command should run");
137 assert!(
138 output.status.success(),
139 "git {:?} failed: stdout={} stderr={}",
140 args,
141 String::from_utf8_lossy(&output.stdout),
142 String::from_utf8_lossy(&output.stderr)
143 );
144 }
145
146 fn make_non_bare_entry(path: &Path) -> crate::repos::RepoEntry {
147 crate::repos::RepoEntry {
148 name: "repo".to_string(),
149 path: path.to_path_buf(),
150 bare: false,
151 }
152 }
153
154 fn make_bare_entry(path: &Path) -> crate::repos::RepoEntry {
155 crate::repos::RepoEntry {
156 name: "repo".to_string(),
157 path: path.to_path_buf(),
158 bare: true,
159 }
160 }
161
162 #[test]
163 fn read_description_prefers_tracked_description_for_bare_repo() {
164 let tmp = TempDir::new().unwrap();
165 let bare_repo = tmp.path().join("repo.git");
166 let work_repo = tmp.path().join("work");
167
168 git(tmp.path(), &["init", "--bare", "repo.git"]);
169 git(tmp.path(), &["clone", bare_repo.to_str().unwrap(), "work"]);
170 git(&work_repo, &["config", "user.name", "Test User"]);
171 git(&work_repo, &["config", "user.email", "test@example.com"]);
172 git(&work_repo, &["checkout", "-b", "main"]);
173 std::fs::write(work_repo.join(".description"), "Tracked description\n").unwrap();
174 git(&work_repo, &["add", ".description"]);
175 git(&work_repo, &["commit", "-m", "Add tracked description"]);
176 git(&work_repo, &["push", "-u", "origin", "main"]);
177
178 std::fs::write(bare_repo.join("description"), "Local bare description\n").unwrap();
179
180 let entry = make_bare_entry(&bare_repo);
181 assert_eq!(read_description(&entry), "Tracked description");
182 }
183
184 #[test]
185 fn read_local_description_falls_back_to_git_metadata_file() {
186 let tmp = TempDir::new().unwrap();
187 let repo_path = tmp.path().join("repo");
188
189 git(tmp.path(), &["init", "repo"]);
190 std::fs::write(repo_path.join(".git").join("description"), "Local repo description\n").unwrap();
191
192 let entry = make_non_bare_entry(&repo_path);
193 assert_eq!(read_local_description(&entry), Some("Local repo description".to_string()));
194 assert_eq!(read_description(&entry), "Local repo description");
195 }
196 }
src/server/http/templates/base.html
Old New
@@ -6,7 +6,7 @@
6 <title>{% block title %}{{ site_title }}{% endblock %}</title> 6 <title>{% block title %}{{ site_title }}{% endblock %}</title>
7 <style> 7 <style>
8 * { box-sizing: border-box; margin: 0; padding: 0; } 8 * { box-sizing: border-box; margin: 0; padding: 0; }
9 body { font-family: monospace; font-size: 14px; line-height: 1.6; } 9 body { font-family: monospace; font-size: 14px; line-height: 1.6; color: #24292f; background: #fff; }
10 a { color: #0366d6; } 10 a { color: #0366d6; }
11 table { border-collapse: collapse; width: 100%; } 11 table { border-collapse: collapse; width: 100%; }
12 th, td { text-align: left; padding: 4px 8px; } 12 th, td { text-align: left; padding: 4px 8px; }
@@ -16,16 +16,30 @@
16 .badge { font-size: 11px; color: #666; } 16 .badge { font-size: 11px; color: #666; }
17 .status-open { color: green; } 17 .status-open { color: green; }
18 .status-closed { color: red; } 18 .status-closed { color: red; }
19 .diff-add { color: green; } 19 .diff-add { color: #1a7f37; background: #eaf6ec; }
20 .diff-del { color: red; } 20 .diff-del { color: #cf222e; background: #ffebe9; }
21 .diff-hunk { color: #666; } 21 .diff-hunk { color: #57606a; background: #f6f8fa; }
22 .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } 22 .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; }
23 .diff-ctx { color: #24292f; background: #fff; }
24 .diff-empty { background: #f6f8fa; color: transparent; }
23 .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; } 25 .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; }
24 .header a { text-decoration: none; } 26 .header a { text-decoration: none; }
25 .header .site { font-weight: bold; } 27 .header .site { font-weight: bold; }
26 .header nav { display: flex; gap: 12px; } 28 .header nav { display: flex; gap: 12px; }
27 .header nav a.active { font-weight: bold; } 29 .header nav a.active { font-weight: bold; }
28 .content { padding: 16px; max-width: 1200px; margin: 0 auto; } 30 .content { padding: 16px; max-width: 1500px; margin: 0 auto; }
31 .commit-message { padding: 12px; border: 1px solid #d0d7de; border-radius: 6px; white-space: pre-wrap; }
32 .diff-file-block { border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; }
33 .diff-file-heading { padding: 10px 12px; background: #f6f8fa; border-bottom: 1px solid #d0d7de; }
34 .side-by-side-diff { table-layout: fixed; }
35 .side-by-side-diff col.diff-line-col { width: 4rem; }
36 .side-by-side-diff col.diff-code-col { width: auto; }
37 .side-by-side-diff thead th { background: #f6f8fa; border-bottom: 1px solid #d0d7de; }
38 .side-by-side-diff thead th:nth-child(1), .side-by-side-diff thead th:nth-child(2) { border-right: 1px solid #d8dee4; }
39 .side-by-side-diff td { border-top: 1px solid #d8dee4; vertical-align: top; padding: 2px 6px; }
40 .side-by-side-diff td:nth-child(2) { border-right: 1px solid #d8dee4; }
41 .diff-line-no { text-align: right; color: #57606a; user-select: none; padding-right: 10px; }
42 .diff-code { white-space: pre-wrap; word-break: break-word; }
29 </style> 43 </style>
30 </head> 44 </head>
31 <body> 45 <body>
src/server/http/templates/diff.html
Old New
@@ -3,12 +3,65 @@
3 {% block title %}{{ short_id }} — {{ repo_name }} — {{ site_title }}{% endblock %} 3 {% block title %}{{ short_id }} — {{ repo_name }} — {{ site_title }}{% endblock %}
4 4
5 {% block content %} 5 {% block content %}
6 <h2 class="mono">{{ short_id }}</h2> 6 <section style="display: grid; gap: 12px;">
7 <p><strong>{{ summary }}</strong></p> 7 <div>
8 <p style="color: #666;">{{ author }} &nbsp; {{ date }}</p> 8 <h2 class="mono">{{ short_id }}</h2>
9 {% if !body.is_empty() %} 9 <p><strong>{{ summary }}</strong></p>
10 <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ body }}</pre> 10 <p style="color: #666;">{{ author }} &nbsp; {{ date }}</p>
11 {% endif %} 11 </div>
12
13 {% if !full_message.is_empty() %}
14 <div>
15 <div style="font-size: 12px; color: #666; margin-bottom: 6px;">Commit message</div>
16 <pre class="commit-message">{{ full_message }}</pre>
17 </div>
18 {% endif %}
19 </section>
20
12 <hr style="margin: 16px 0;"> 21 <hr style="margin: 16px 0;">
13 <pre class="diff">{% for line in diff_lines %}{% if line.kind == "add" %}<span class="diff-add">{{ line.text }}</span>{% elif line.kind == "del" %}<span class="diff-del">{{ line.text }}</span>{% elif line.kind == "hunk" %}<span class="diff-hunk">{{ line.text }}</span>{% elif line.kind == "file" %}<span class="diff-file">{{ line.text }}</span>{% else %}{{ line.text }}{% endif %}{% endfor %}</pre> 22
23 {% if diff_files.is_empty() %}
24 <p style="color: #666;">No diff available for this commit.</p>
25 {% else %}
26 <div style="display: grid; gap: 16px;">
27 {% for file in diff_files %}
28 <section class="diff-file-block">
29 <div class="diff-file-heading mono">{{ file.path }}</div>
30 {% if file.hunks.is_empty() %}
31 <div style="padding: 12px; color: #666;">No textual changes available.</div>
32 {% else %}
33 <table class="side-by-side-diff">
34 <colgroup>
35 <col class="diff-line-col">
36 <col class="diff-code-col">
37 <col class="diff-line-col">
38 <col class="diff-code-col">
39 </colgroup>
40 <thead>
41 <tr>
42 <th colspan="2">Old</th>
43 <th colspan="2">New</th>
44 </tr>
45 </thead>
46 <tbody>
47 {% for hunk in file.hunks %}
48 <tr>
49 <td class="diff-hunk" colspan="4">{{ hunk.header }}</td>
50 </tr>
51 {% for row in hunk.rows %}
52 <tr>
53 <td class="diff-line-no diff-{{ row.left.kind }}">{{ row.left.line_no }}</td>
54 <td class="diff-code diff-{{ row.left.kind }}">{{ row.left.text }}</td>
55 <td class="diff-line-no diff-{{ row.right.kind }}">{{ row.right.line_no }}</td>
56 <td class="diff-code diff-{{ row.right.kind }}">{{ row.right.text }}</td>
57 </tr>
58 {% endfor %}
59 {% endfor %}
60 </tbody>
61 </table>
62 {% endif %}
63 </section>
64 {% endfor %}
65 </div>
66 {% endif %}
14 {% endblock %} 67 {% endblock %}