Format TUI commit activity previews
This commit is contained in:
@@ -55,6 +55,9 @@ representative repositories.
|
|||||||
double-clicking opens it, and the mouse wheel scrolls the list.
|
double-clicking opens it, and the mouse wheel scrolls the list.
|
||||||
- [ ] Home activity filters cycle with `v`; activity targets route to their
|
- [ ] Home activity filters cycle with `v`; activity targets route to their
|
||||||
repository, issue, pull request, or commit.
|
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
|
- [ ] Repository favorites toggle with `*`, sort first per pane, and persist
|
||||||
after relaunch. Commits switch branches with `b`; file trees, text and
|
after relaunch. Commits switch branches with `b`; file trees, text and
|
||||||
binary files, commit changes, pull changes, and per-file diffs open.
|
binary files, commit changes, pull changes, and per-file diffs open.
|
||||||
|
|||||||
@@ -164,25 +164,23 @@ fn activity_row(activity: &models::Activity) -> ActivityRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activity_detail(activity: &models::Activity) -> String {
|
fn activity_detail(activity: &models::Activity) -> String {
|
||||||
let text = activity
|
let comment = activity
|
||||||
.comment
|
.comment
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|comment| comment.body.as_deref())
|
.and_then(|comment| comment.body.as_deref());
|
||||||
.or(activity.content.as_deref())
|
let text = comment.or(activity.content.as_deref()).unwrap_or("");
|
||||||
.unwrap_or("");
|
if comment.is_none()
|
||||||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
|
&& let Some(payload) = activity::commit_activity(activity)
|
||||||
let commits = payload.get("Len").and_then(|value| value.as_i64());
|
{
|
||||||
let message = payload
|
let message = payload
|
||||||
.get("HeadCommit")
|
.commits
|
||||||
.and_then(|commit| commit.get("Message"))
|
.last()
|
||||||
.and_then(|message| message.as_str())
|
.map(|commit| summary(&commit.message))
|
||||||
.map(summary)
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !message.is_empty() {
|
if !message.is_empty() {
|
||||||
return match commits {
|
return match payload.count {
|
||||||
Some(1) => format!("1 commit · {message}"),
|
1 => format!("1 commit · {message}"),
|
||||||
Some(count) => format!("{count} commits · {message}"),
|
count => format!("{count} commits · {message}"),
|
||||||
None => message,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ pub enum Target {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CommitActivity {
|
||||||
|
pub count: usize,
|
||||||
|
pub commits: Vec<ActivityCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActivityCommit {
|
||||||
|
pub sha: String,
|
||||||
|
pub message: String,
|
||||||
|
pub author: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
pub async fn activities(
|
pub async fn activities(
|
||||||
&self,
|
&self,
|
||||||
@@ -205,6 +219,28 @@ pub fn target(activity: &models::Activity) -> Option<Target> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn commit_activity(activity: &models::Activity) -> Option<CommitActivity> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<i64> {
|
fn issue_number(activity: &models::Activity) -> Option<i64> {
|
||||||
activity
|
activity
|
||||||
.comment
|
.comment
|
||||||
@@ -233,13 +269,14 @@ fn url_number(url: &str) -> Option<i64> {
|
|||||||
|
|
||||||
fn commit_sha(content: &str) -> Option<String> {
|
fn commit_sha(content: &str) -> Option<String> {
|
||||||
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||||
payload.get("HeadCommit").and_then(find_sha).or_else(|| {
|
field(&payload, "HeadCommit")
|
||||||
payload
|
.and_then(find_sha)
|
||||||
.get("Commits")?
|
.or_else(|| {
|
||||||
.as_array()?
|
field(&payload, "Commits")?
|
||||||
.last()
|
.as_array()?
|
||||||
.and_then(find_sha)
|
.last()
|
||||||
})
|
.and_then(find_sha)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
||||||
@@ -252,6 +289,35 @@ fn find_sha(value: &serde_json::Value) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn activity_commit(value: &serde_json::Value) -> Option<ActivityCommit> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn filters_issue_and_pull_request_activity() {
|
fn filters_issue_and_pull_request_activity() {
|
||||||
let activities = [
|
let activities = [
|
||||||
|
|||||||
@@ -1163,12 +1163,7 @@ impl App {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|repo| repo.full_name.as_deref())
|
.and_then(|repo| repo.full_name.as_deref())
|
||||||
.unwrap_or("repository");
|
.unwrap_or("repository");
|
||||||
let detail = activity
|
let detail = activity_detail(&activity);
|
||||||
.comment
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|comment| comment.body.clone())
|
|
||||||
.or(activity.content.clone())
|
|
||||||
.unwrap_or_else(|| "Server activity".into());
|
|
||||||
let target = match gotcha_gitea::activity::target(&activity) {
|
let target = match gotcha_gitea::activity::target(&activity) {
|
||||||
Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => {
|
Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => {
|
||||||
Target::Repository(
|
Target::Repository(
|
||||||
@@ -1869,6 +1864,49 @@ fn short_sha(sha: &str) -> &str {
|
|||||||
sha.get(..8).unwrap_or(sha)
|
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::<Vec<_>>()
|
||||||
|
.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 {
|
fn err(error: gotcha_gitea::Error) -> String {
|
||||||
error.to_string()
|
error.to_string()
|
||||||
}
|
}
|
||||||
@@ -1950,4 +1988,23 @@ mod tests {
|
|||||||
assert_eq!(app.history.len(), 1);
|
assert_eq!(app.history.len(), 1);
|
||||||
assert!(app.confirm.is_some());
|
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\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user