branch: main
commit.rs
12784 bytesRaw
use super::*;
const MAX_HUNKS_PER_FILE: usize = 8;
const MAX_LINES_PER_HUNK: usize = 18;
struct CommitPage {
owner: String,
repo_name: String,
hash: String,
default_branch: String,
commit: CommitMeta,
diff_result: diff::CommitDiff,
}
enum CommitMarkdownRoute {
Commit,
Diff,
}
impl CommitPage {
fn short_hash(&self) -> &str {
&self.hash[..7.min(self.hash.len())]
}
fn subject(&self) -> String {
first_line(&self.commit.message)
}
fn body(&self) -> String {
rest_of_message(&self.commit.message)
}
fn commit_path(&self, hash: &str) -> String {
format!("/{}/{}/commit/{}", self.owner, self.repo_name, hash)
}
fn diff_path(&self, hash: &str) -> String {
format!("/{}/{}/diff/{}", self.owner, self.repo_name, hash)
}
}
fn build_commit_page(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
hash: &str,
) -> Result<CommitPage> {
if hash.is_empty() {
return Err(Error::RustError("missing commit hash".into()));
}
let (default_branch, _) = resolve_default_branch(sql)?;
let commit = load_commit_meta(sql, hash)?;
let diff_result = diff::diff_commit(sql, hash, true, 3)?;
Ok(CommitPage {
owner: owner.to_string(),
repo_name: repo_name.to_string(),
hash: hash.to_string(),
default_branch,
commit,
diff_result,
})
}
fn render_commit_html(page: &CommitPage, actor_name: Option<&str>) -> String {
let mut html = String::new();
html.push_str(&format!(
r#"<h1 style="font-size:18px;margin-bottom:4px">{msg}</h1>"#,
msg = html_escape(&page.subject()),
));
html.push_str(&format!(
r#"<p style="color:#656d76;margin-bottom:16px">{author} <{email}> committed {time}</p>"#,
author = html_escape(&page.commit.author),
email = html_escape(&page.commit.author_email),
time = format_time(page.commit.commit_time),
));
let rest = page.body();
if !rest.is_empty() {
html.push_str(&format!(
r#"<pre style="margin-bottom:16px;padding:12px;background:#f6f8fa;border-radius:6px;white-space:pre-wrap">{}</pre>"#,
html_escape(&rest),
));
}
html.push_str(&format!(
r#"<div style="font-family:monospace;font-size:13px;margin-bottom:16px;color:#656d76">
commit {hash}<br>
{parents}
</div>"#,
hash = page.hash,
parents = if let Some(ref parent_hash) = page.diff_result.parent_hash {
format!(
r#"parent <a href="/{}/{}/commit/{}">{}</a>"#,
page.owner,
page.repo_name,
parent_hash,
&parent_hash[..7.min(parent_hash.len())]
)
} else {
"root commit".to_string()
},
));
html.push_str(&render_stats_html(&page.diff_result.stats));
for file in &page.diff_result.files {
html.push_str(&render_file_diff(file));
}
layout(
&format!("Commit {}", page.short_hash()),
&page.owner,
&page.repo_name,
&page.default_branch,
actor_name,
&html,
)
}
fn render_stats_html(stats: &diff::DiffStats) -> String {
format!(
r#"<div class="diff-stats">
Showing <strong>{files}</strong> changed file{s} with
<span class="stat-add">+{add}</span> addition{as_} and
<span class="stat-del">-{del}</span> deletion{ds}.
</div>"#,
files = stats.files_changed,
s = plural_suffix(stats.files_changed),
add = stats.additions,
as_ = plural_suffix(stats.additions),
del = stats.deletions,
ds = plural_suffix(stats.deletions),
)
}
fn render_commit_markdown(
page: &CommitPage,
route: CommitMarkdownRoute,
selection: &NegotiatedRepresentation,
) -> String {
let route_path = match route {
CommitMarkdownRoute::Commit => page.commit_path(&page.hash),
CommitMarkdownRoute::Diff => page.diff_path(&page.hash),
};
let route_label = match route {
CommitMarkdownRoute::Commit => "commit",
CommitMarkdownRoute::Diff => "diff",
};
let mut markdown = format!(
"# {}/{} {} `{}`\n\nSubject: {}\nAuthor: {} <{}>\nCommitted: {}\nCommit: `{}`\n",
page.owner,
page.repo_name,
route_label,
page.short_hash(),
page.subject(),
page.commit.author,
page.commit.author_email,
format_time(page.commit.commit_time),
page.hash,
);
if let Some(parent_hash) = &page.diff_result.parent_hash {
markdown.push_str(&format!(
"Parent: `{}` - `{}`\n",
&parent_hash[..7.min(parent_hash.len())],
page.commit_path(parent_hash)
));
} else {
markdown.push_str("Parent: root commit\n");
}
markdown.push_str(&format!(
"Stats: {} file{} changed, +{}, -{}\n",
page.diff_result.stats.files_changed,
plural_suffix(page.diff_result.stats.files_changed),
page.diff_result.stats.additions,
page.diff_result.stats.deletions,
));
let body = page.body();
if !body.is_empty() {
markdown.push_str("\n## Message\n\n");
markdown.push_str(&markdown_literal_block(&body));
}
markdown.push_str("\n## Changed Files\n");
if page.diff_result.files.is_empty() {
markdown.push_str("No file changes.\n");
} else {
for file in &page.diff_result.files {
markdown.push_str(&format!(
"- `{}` `{}` - +{}, -{}, {}\n",
diff_status_letter(&file.status),
file.path,
file_additions(file),
file_deletions(file),
hunk_summary(file)
));
}
}
let detail_sections = page
.diff_result
.files
.iter()
.filter_map(render_file_markdown_details)
.collect::<Vec<_>>();
if !detail_sections.is_empty() {
markdown.push_str("\n## Diff Details\n");
for section in detail_sections {
markdown.push_str(§ion);
}
}
let commit_json_path = presentation::append_format(
&page.commit_path(&page.hash),
presentation::Representation::Json,
);
let diff_json_path = presentation::append_format(
&page.diff_path(&page.hash),
presentation::Representation::Json,
);
let mut actions = vec![
Action::get(route_path, format!("reload this {} page", route_label)),
Action::get(page.commit_path(&page.hash), "open the commit route"),
Action::get(page.diff_path(&page.hash), "open the diff route"),
Action::get(commit_json_path, "fetch the structured commit record"),
Action::get(diff_json_path, "fetch the structured diff with hunks"),
];
if let Some(parent_hash) = &page.diff_result.parent_hash {
actions.push(Action::get(
page.commit_path(parent_hash),
"inspect the parent commit",
));
}
let mut hints = vec![
presentation::text_navigation_hint(*selection),
Hint::new("`/commit/:hash` and `/diff/:hash` can share this same markdown page model; the JSON endpoints differ."),
Hint::new(format!(
"Use `{}?context=N&format=json` for more or less diff context, or add `&stat=1` for stats only.",
page.diff_path(&page.hash)
)),
];
if page.diff_result.parent_hash.is_none() {
hints.push(Hint::new(
"Root commits have no parent navigation target; every listed file is introduced here.",
));
}
markdown.push_str(&presentation::render_actions_section(&actions));
markdown.push_str(&presentation::render_hints_section(&hints));
markdown
}
fn render_file_markdown_details(file: &diff::FileDiff) -> Option<String> {
let hunks = file.hunks.as_ref()?;
let mut section = String::new();
section.push_str(&format!(
"\n### `{}` `{}`\n\n",
diff_status_letter(&file.status),
file.path
));
section.push_str(&format!(
"Summary: +{}, -{}, {}\n\n",
file_additions(file),
file_deletions(file),
hunk_summary(file)
));
for (idx, hunk) in hunks.iter().enumerate() {
if idx >= MAX_HUNKS_PER_FILE {
section.push_str(&format!(
"{} more hunk{} omitted.\n\n",
hunks.len() - MAX_HUNKS_PER_FILE,
plural_suffix(hunks.len() - MAX_HUNKS_PER_FILE)
));
break;
}
if hunk.lines.len() == 1 && hunk.lines[0].tag == "binary" {
section.push_str(" Binary files differ\n\n");
continue;
}
section.push_str(&format!(
" @@ -{},{} +{},{} @@\n",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
));
for (line_idx, line) in hunk.lines.iter().enumerate() {
if line_idx >= MAX_LINES_PER_HUNK {
section.push_str(&format!(
" ... {} more line{} omitted ...\n",
hunk.lines.len() - MAX_LINES_PER_HUNK,
plural_suffix(hunk.lines.len() - MAX_LINES_PER_HUNK)
));
break;
}
let prefix = match line.tag {
"add" => '+',
"delete" => '-',
"binary" => '!',
_ => ' ',
};
section.push_str(" ");
section.push(prefix);
section.push_str(line.content.trim_end_matches('\n'));
section.push('\n');
}
section.push('\n');
}
Some(section)
}
fn diff_status_letter(status: &diff::DiffStatus) -> &'static str {
match status {
diff::DiffStatus::Added => "A",
diff::DiffStatus::Deleted => "D",
diff::DiffStatus::Modified => "M",
}
}
fn file_additions(file: &diff::FileDiff) -> usize {
file.hunks
.as_ref()
.map(|hunks| {
hunks
.iter()
.flat_map(|hunk| hunk.lines.iter())
.filter(|line| line.tag == "add")
.count()
})
.unwrap_or_else(|| match file.status {
diff::DiffStatus::Added => 1,
diff::DiffStatus::Deleted => 0,
diff::DiffStatus::Modified => 1,
})
}
fn file_deletions(file: &diff::FileDiff) -> usize {
file.hunks
.as_ref()
.map(|hunks| {
hunks
.iter()
.flat_map(|hunk| hunk.lines.iter())
.filter(|line| line.tag == "delete")
.count()
})
.unwrap_or_else(|| match file.status {
diff::DiffStatus::Added => 0,
diff::DiffStatus::Deleted => 1,
diff::DiffStatus::Modified => 1,
})
}
fn hunk_summary(file: &diff::FileDiff) -> String {
match &file.hunks {
Some(hunks) => format!("{} hunk{}", hunks.len(), plural_suffix(hunks.len())),
None => "no hunk detail".to_string(),
}
}
fn plural_suffix(count: usize) -> &'static str {
if count == 1 {
""
} else {
"s"
}
}
fn markdown_literal_block(text: &str) -> String {
let mut output = String::new();
for line in text.lines() {
output.push_str(" ");
output.push_str(line);
output.push('\n');
}
if output.is_empty() {
output.push_str(" \n");
}
output
}
pub fn page_commit(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
hash: &str,
actor_name: Option<&str>,
) -> Result<Response> {
let page = build_commit_page(sql, owner, repo_name, hash)?;
html_response(&render_commit_html(&page, actor_name))
}
pub fn page_commit_markdown(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
hash: &str,
selection: &NegotiatedRepresentation,
) -> Result<Response> {
let page = build_commit_page(sql, owner, repo_name, hash)?;
presentation::markdown_response(
&render_commit_markdown(&page, CommitMarkdownRoute::Commit, selection),
selection,
)
}
pub fn page_diff_markdown(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
hash: &str,
selection: &NegotiatedRepresentation,
) -> Result<Response> {
let page = build_commit_page(sql, owner, repo_name, hash)?;
presentation::markdown_response(
&render_commit_markdown(&page, CommitMarkdownRoute::Diff, selection),
selection,
)
}
fn rest_of_message(message: &str) -> String {
let mut lines = message.lines();
lines.next();
let rest: String = lines.collect::<Vec<_>>().join("\n");
rest.trim().to_string()
}