diff --git a/TESTING.md b/TESTING.md index 2f8ad11..e6c8585 100644 --- a/TESTING.md +++ b/TESTING.md @@ -55,6 +55,9 @@ representative repositories. double-clicking opens it, and the mouse wheel scrolls the list. - [ ] Home activity filters cycle with `v`; activity targets route to their repository, issue, pull request, or commit. +- [ ] A commit activity preview shows its commit count, short hashes, messages, + authors, and timestamps as a readable list rather than raw JSON; opening + it shows the head commit and its changed files. - [ ] Repository favorites toggle with `*`, sort first per pane, and persist after relaunch. Commits switch branches with `b`; file trees, text and binary files, commit changes, pull changes, and per-file diffs open. diff --git a/crates/app/src/presentation/home.rs b/crates/app/src/presentation/home.rs index 4900eff..9eee7a9 100644 --- a/crates/app/src/presentation/home.rs +++ b/crates/app/src/presentation/home.rs @@ -164,25 +164,23 @@ fn activity_row(activity: &models::Activity) -> ActivityRow { } fn activity_detail(activity: &models::Activity) -> String { - let text = activity + let comment = activity .comment .as_ref() - .and_then(|comment| comment.body.as_deref()) - .or(activity.content.as_deref()) - .unwrap_or(""); - if let Ok(payload) = serde_json::from_str::(text) { - let commits = payload.get("Len").and_then(|value| value.as_i64()); + .and_then(|comment| comment.body.as_deref()); + let text = comment.or(activity.content.as_deref()).unwrap_or(""); + if comment.is_none() + && let Some(payload) = activity::commit_activity(activity) + { let message = payload - .get("HeadCommit") - .and_then(|commit| commit.get("Message")) - .and_then(|message| message.as_str()) - .map(summary) + .commits + .last() + .map(|commit| summary(&commit.message)) .unwrap_or_default(); if !message.is_empty() { - return match commits { - Some(1) => format!("1 commit · {message}"), - Some(count) => format!("{count} commits · {message}"), - None => message, + return match payload.count { + 1 => format!("1 commit · {message}"), + count => format!("{count} commits · {message}"), }; } } diff --git a/crates/gitea/src/activity.rs b/crates/gitea/src/activity.rs index 47253cb..336ee5a 100644 --- a/crates/gitea/src/activity.rs +++ b/crates/gitea/src/activity.rs @@ -70,6 +70,20 @@ pub enum Target { }, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommitActivity { + pub count: usize, + pub commits: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActivityCommit { + pub sha: String, + pub message: String, + pub author: String, + pub timestamp: String, +} + impl Client { pub async fn activities( &self, @@ -205,6 +219,28 @@ pub fn target(activity: &models::Activity) -> Option { } } +pub fn commit_activity(activity: &models::Activity) -> Option { + let payload: serde_json::Value = serde_json::from_str(activity.content.as_deref()?).ok()?; + let mut commits = field(&payload, "Commits") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(activity_commit) + .collect::>(); + if commits.is_empty() + && let Some(commit) = field(&payload, "HeadCommit").and_then(activity_commit) + { + commits.push(commit); + } + (!commits.is_empty()).then(|| CommitActivity { + count: field(&payload, "Len") + .and_then(serde_json::Value::as_u64) + .and_then(|count| usize::try_from(count).ok()) + .unwrap_or(commits.len()), + commits, + }) +} + fn issue_number(activity: &models::Activity) -> Option { activity .comment @@ -233,13 +269,14 @@ fn url_number(url: &str) -> Option { fn commit_sha(content: &str) -> Option { let payload: serde_json::Value = serde_json::from_str(content).ok()?; - payload.get("HeadCommit").and_then(find_sha).or_else(|| { - payload - .get("Commits")? - .as_array()? - .last() - .and_then(find_sha) - }) + field(&payload, "HeadCommit") + .and_then(find_sha) + .or_else(|| { + field(&payload, "Commits")? + .as_array()? + .last() + .and_then(find_sha) + }) } fn find_sha(value: &serde_json::Value) -> Option { @@ -252,6 +289,35 @@ fn find_sha(value: &serde_json::Value) -> Option { }) } +fn activity_commit(value: &serde_json::Value) -> Option { + let sha = find_sha(value).unwrap_or_default(); + let message = string_field(value, "Message") + .unwrap_or_default() + .to_string(); + (!sha.is_empty() || !message.is_empty()).then(|| ActivityCommit { + sha, + message, + author: string_field(value, "AuthorName") + .or_else(|| string_field(value, "CommitterName")) + .unwrap_or_default() + .to_string(), + timestamp: string_field(value, "Timestamp") + .unwrap_or_default() + .to_string(), + }) +} + +fn string_field<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> { + field(value, name).and_then(serde_json::Value::as_str) +} + +fn field<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a serde_json::Value> { + value + .as_object()? + .iter() + .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value)) +} + #[cfg(test)] mod tests { use super::*; @@ -299,6 +365,36 @@ mod tests { ); } + #[test] + fn decodes_forgejo_commit_activity_for_presentation() { + let activity = activity( + OpType::CommitRepo, + r#"{ + "Commits": [{ + "Sha1": "0123456789abcdef", + "Message": "Fix the preview\n\nDetails", + "AuthorName": "Octo Cat", + "Timestamp": "2026-08-04T19:04:30+02:00" + }], + "HeadCommit": {"Sha1": "0123456789abcdef"}, + "Len": 1 + }"#, + ); + + assert_eq!( + commit_activity(&activity), + Some(CommitActivity { + count: 1, + commits: vec![ActivityCommit { + sha: "0123456789abcdef".into(), + message: "Fix the preview\n\nDetails".into(), + author: "Octo Cat".into(), + timestamp: "2026-08-04T19:04:30+02:00".into(), + }], + }) + ); + } + #[test] fn filters_issue_and_pull_request_activity() { let activities = [ diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index ebb1860..7089ae1 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1163,12 +1163,7 @@ impl App { .as_ref() .and_then(|repo| repo.full_name.as_deref()) .unwrap_or("repository"); - let detail = activity - .comment - .as_ref() - .and_then(|comment| comment.body.clone()) - .or(activity.content.clone()) - .unwrap_or_else(|| "Server activity".into()); + let detail = activity_detail(&activity); let target = match gotcha_gitea::activity::target(&activity) { Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => { Target::Repository( @@ -1869,6 +1864,49 @@ fn short_sha(sha: &str) -> &str { sha.get(..8).unwrap_or(sha) } +fn activity_detail(activity: &models::Activity) -> String { + if let Some(body) = activity + .comment + .as_ref() + .and_then(|comment| comment.body.as_deref()) + { + return body.to_string(); + } + if let Some(payload) = gotcha_gitea::activity::commit_activity(activity) { + let noun = if payload.count == 1 { + "commit" + } else { + "commits" + }; + let mut detail = format!("**{} {noun}**", payload.count); + for commit in payload.commits { + let message = commit.message.lines().next().unwrap_or("Commit"); + detail.push_str("\n\n- "); + if !commit.sha.is_empty() { + detail.push('`'); + detail.push_str(short_sha(&commit.sha)); + detail.push_str("` "); + } + detail.push_str(message); + let metadata = [commit.author.as_str(), commit.timestamp.as_str()] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(" · "); + if !metadata.is_empty() { + detail.push_str(" \n "); + detail.push_str(&metadata); + } + } + return detail; + } + activity + .content + .clone() + .filter(|content| !content.is_empty()) + .unwrap_or_else(|| "Server activity".into()) +} + fn err(error: gotcha_gitea::Error) -> String { error.to_string() } @@ -1950,4 +1988,23 @@ mod tests { assert_eq!(app.history.len(), 1); assert!(app.confirm.is_some()); } + + #[test] + fn commit_activity_preview_formats_the_payload() { + let activity = models::Activity { + content: Some( + r#"{"Commits":[{"Sha1":"0123456789abcdef","Message":"Fix preview\n\nDetails","AuthorName":"Octo Cat","Timestamp":"2026-08-04T19:04:30+02:00"}],"Len":1}"# + .into(), + ), + ..Default::default() + }; + + let detail = activity_detail(&activity); + + assert_eq!( + detail, + "**1 commit**\n\n- `01234567` Fix preview \n Octo Cat · 2026-08-04T19:04:30+02:00" + ); + assert!(!detail.contains("\"Commits\"")); + } }