Add activity type icons to the TUI (#68)

This commit is contained in:
Georg Bauer
2026-08-15 15:10:17 +02:00
parent bac15ab34a
commit e5131f663c
4 changed files with 288 additions and 21 deletions

1
Cargo.lock generated
View File

@@ -902,6 +902,7 @@ dependencies = [
"syntect",
"tokio",
"tui-markdown",
"unicode-width",
]
[[package]]

View File

@@ -17,3 +17,4 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm_0
syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] }
tokio.workspace = true
tui-markdown = { version = "0.3", default-features = false }
unicode-width = "0.2"

View File

@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
time::{Duration, Instant},
};
@@ -103,11 +103,43 @@ pub struct Item {
pub key: String,
pub title: String,
pub graph_lane: Option<usize>,
pub activity_kind: Option<ActivityKind>,
pub meta: String,
pub detail: String,
pub target: Target,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActivityKind {
Repository,
Issue,
PullRequest,
Branch,
Tag,
Push,
Release,
}
impl ActivityKind {
const fn label(self) -> &'static str {
match self {
Self::Repository => "Repository activity",
Self::Issue => "Issue activity",
Self::PullRequest => "Pull request activity",
Self::Branch => "Branch activity",
Self::Tag => "Tag activity",
Self::Push => "Commit activity",
Self::Release => "Release activity",
}
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum ActivityTitleKey {
Issue(String, String, String, i64),
Pull(String, String, String, i64),
}
#[derive(Clone, Debug)]
pub struct Screen {
pub kind: ScreenKind,
@@ -180,6 +212,7 @@ pub struct App {
pull_milestone: String,
pull_search: String,
branches: BTreeMap<String, String>,
activity_titles: BTreeMap<ActivityTitleKey, Option<String>>,
last_click: Option<(usize, Instant)>,
}
@@ -237,6 +270,7 @@ impl App {
pull_milestone: String::new(),
pull_search: String::new(),
branches: BTreeMap::new(),
activity_titles: BTreeMap::new(),
last_click: None,
};
app.reload().await;
@@ -1091,7 +1125,7 @@ impl App {
Ok(())
}
async fn load_screen(&self, kind: ScreenKind, page: u32) -> Result<Screen, String> {
async fn load_screen(&mut self, kind: ScreenKind, page: u32) -> Result<Screen, String> {
match kind {
ScreenKind::Servers => self.load_servers(),
ScreenKind::Home => self.load_home(page).await,
@@ -1133,6 +1167,7 @@ impl App {
name.clone()
},
graph_lane: None,
activity_kind: None,
meta: format!("{} · {}", server.provider, server.url),
detail: format!(
"Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••",
@@ -1144,12 +1179,13 @@ impl App {
Ok(screen)
}
async fn load_home(&self, page: u32) -> Result<Screen, String> {
async fn load_home(&mut self, page: u32) -> Result<Screen, String> {
let data = self
.client
.home(page as i32, self.activity_filter)
.await
.map_err(err)?;
self.load_activity_titles(&data.activities).await;
let contributions: i64 = data
.heatmap
.iter()
@@ -1172,6 +1208,11 @@ impl App {
.as_ref()
.and_then(|repo| repo.full_name.as_deref())
.unwrap_or("repository");
let activity_kind = activity_kind(&activity);
let resolved_title = activity_title_key(&self.server_name, &activity)
.and_then(|key| self.activity_titles.get(&key))
.and_then(Option::as_deref);
let title = activity_title(&activity, resolved_title, repository);
let detail = activity_detail(&activity);
let target = match gotcha_gitea::activity::target(&activity) {
Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => {
@@ -1202,8 +1243,9 @@ impl App {
"{}-{index}",
activity.created.as_deref().unwrap_or_default()
),
title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()),
title,
graph_lane: None,
activity_kind: Some(activity_kind),
meta: activity.created.unwrap_or_default(),
detail,
target,
@@ -1213,6 +1255,50 @@ impl App {
Ok(screen)
}
async fn load_activity_titles(&mut self, activities: &[models::Activity]) {
let mut requests = tokio::task::JoinSet::new();
let mut pending = BTreeSet::new();
for key in activities
.iter()
.filter_map(|activity| activity_title_key(&self.server_name, activity))
{
if self.activity_titles.contains_key(&key) || !pending.insert(key.clone()) {
continue;
}
let client = self.client.clone();
requests.spawn(async move {
let title = match &key {
ActivityTitleKey::Issue(_, owner, repository, number) => client
.issue(
&RepositoryId {
owner: owner.clone(),
repository: repository.clone(),
},
*number,
)
.await
.ok()
.and_then(|issue| issue.title),
ActivityTitleKey::Pull(_, owner, repository, number) => client
.pull(
&RepositoryId {
owner: owner.clone(),
repository: repository.clone(),
},
*number,
)
.await
.ok()
.and_then(|pull| pull.title),
};
(key, title)
});
}
while let Some(Ok((key, title))) = requests.join_next().await {
self.activity_titles.insert(key, title);
}
}
async fn load_repositories(
&self,
destination: RepositoryDestination,
@@ -1257,6 +1343,7 @@ impl App {
key: format!("{}/{}", id.owner, id.repository),
title: format!("{} {name}", if favorite { "" } else { " " }),
graph_lane: None,
activity_kind: None,
meta: format!(
"{} · {} open · {}",
repository.language.as_deref().unwrap_or(""),
@@ -1357,6 +1444,7 @@ impl App {
.unwrap_or("unknown")
),
graph_lane: None,
activity_kind: None,
meta: comment.updated_at.unwrap_or_default(),
detail: body.clone(),
target: id.map_or(Target::None, |id| {
@@ -1469,6 +1557,7 @@ impl App {
key: path.clone(),
title: format!("{} {name}", if directory { "" } else { " " }),
graph_lane: None,
activity_kind: None,
meta: if directory {
"directory".into()
} else {
@@ -1573,6 +1662,7 @@ impl App {
key: path.clone(),
title: path.clone(),
graph_lane: None,
activity_kind: None,
meta: file.status.unwrap_or_else(|| "modified".into()),
detail: format!(
"+{} -{}",
@@ -1598,6 +1688,7 @@ impl App {
.unwrap_or("unknown")
),
graph_lane: None,
activity_kind: None,
meta: comment.updated_at.unwrap_or_default(),
detail: comment.body.unwrap_or_default(),
target: Target::None,
@@ -1736,6 +1827,7 @@ fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option<Item> {
key: number.to_string(),
title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")),
graph_lane: None,
activity_kind: None,
meta: format!(
"{} · {} · {} comments",
state,
@@ -1759,6 +1851,7 @@ fn pull_item(issue: models::Issue) -> Option<Item> {
issue.title.as_deref().unwrap_or("Untitled")
),
graph_lane: None,
activity_kind: None,
meta: format!(
"{} · {} comments",
issue.state.as_deref().unwrap_or("unknown"),
@@ -1784,6 +1877,7 @@ fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Op
.title
.unwrap_or_else(|| "Untitled milestone".into()),
graph_lane: None,
activity_kind: None,
meta: format!(
"{} · {} open · {} closed",
state,
@@ -1813,6 +1907,7 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option<Item
key: sha.clone(),
title: message.lines().next().unwrap_or("Commit").into(),
graph_lane: node_lane,
activity_kind: None,
meta: format!(
"{} · {}{}",
short_sha(&sha),
@@ -1839,6 +1934,7 @@ fn diff_files(diff: &str) -> Vec<Item> {
key: path.into(),
title: path.into(),
graph_lane: None,
activity_kind: None,
meta: "changed file".into(),
detail: diff_for_file(diff, path),
target: Target::Text(path.into(), diff_for_file(diff, path)),
@@ -1881,6 +1977,74 @@ fn short_sha(sha: &str) -> &str {
sha.get(..8).unwrap_or(sha)
}
fn activity_kind(activity: &models::Activity) -> ActivityKind {
use gotcha_gitea::activity::Target;
use models::activity::OpType;
match gotcha_gitea::activity::target(activity) {
Some(Target::Issue { .. }) => ActivityKind::Issue,
Some(Target::Pull { .. }) => ActivityKind::PullRequest,
Some(Target::Commit { .. }) => ActivityKind::Push,
Some(Target::Repository { .. }) => ActivityKind::Repository,
None => match activity.op_type {
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => {
ActivityKind::Branch
}
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => ActivityKind::Push,
Some(OpType::PushTag | OpType::DeleteTag) => ActivityKind::Tag,
Some(OpType::DeleteBranch) => ActivityKind::Branch,
Some(OpType::PublishRelease) => ActivityKind::Release,
Some(_) | None => ActivityKind::Repository,
},
}
}
fn activity_title_key(server: &str, activity: &models::Activity) -> Option<ActivityTitleKey> {
match gotcha_gitea::activity::target(activity)? {
gotcha_gitea::activity::Target::Issue {
owner,
repository,
number,
} => Some(ActivityTitleKey::Issue(
server.into(),
owner,
repository,
number,
)),
gotcha_gitea::activity::Target::Pull {
owner,
repository,
number,
} => Some(ActivityTitleKey::Pull(
server.into(),
owner,
repository,
number,
)),
_ => None,
}
}
fn activity_title(
activity: &models::Activity,
resolved_title: Option<&str>,
repository: &str,
) -> String {
let title = resolved_title
.filter(|title| !title.is_empty())
.map(str::to_owned)
.or_else(|| {
gotcha_gitea::activity::commit_activity(activity).and_then(|payload| {
payload
.commits
.last()
.map(|commit| commit.message.lines().next().unwrap_or("Commit").to_owned())
})
})
.unwrap_or_else(|| activity_kind(activity).label().into());
format!("{title} · {repository}")
}
fn activity_detail(activity: &models::Activity) -> String {
if let Some(body) = activity
.comment
@@ -2071,4 +2235,31 @@ mod tests {
);
assert!(!detail.contains("\"Commits\""));
}
#[test]
fn issue_activity_uses_the_target_title_instead_of_comment_content() {
let activity = models::Activity {
op_type: Some(models::activity::OpType::CommentIssue),
content: Some("67|Implemented in bac15ab".into()),
repo: Some(Box::new(models::Repository {
full_name: Some("hugo/Gotcha".into()),
..Default::default()
})),
..Default::default()
};
assert_eq!(
activity_title_key("gitea", &activity),
Some(ActivityTitleKey::Issue(
"gitea".into(),
"hugo".into(),
"Gotcha".into(),
67,
))
);
assert_eq!(
activity_title(&activity, Some("Proper release workflow"), "hugo/Gotcha"),
"Proper release workflow · hugo/Gotcha"
);
}
}

View File

@@ -13,9 +13,10 @@ use syntect::{
parsing::SyntaxSet,
util::LinesWithEndings,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::{
app::{App, ScreenKind, Tab},
app::{ActivityKind, App, ScreenKind, Tab},
editor::Editor,
};
@@ -120,7 +121,12 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.iter()
.map(|item| {
ListItem::new(vec![
item_title(&item.title, item.graph_lane),
item_title(
&item.title,
item.graph_lane,
item.activity_kind,
area.width.saturating_sub(4) as usize,
),
Line::from(Span::styled(
item.meta.clone(),
Style::default().fg(Color::DarkGray),
@@ -146,27 +152,67 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
app.list_offset = state.offset();
}
fn item_title(title: &str, graph_lane: Option<usize>) -> Line<'static> {
let Some(node_lane) = graph_lane else {
return Line::from(Span::styled(
title.to_owned(),
Style::default().add_modifier(Modifier::BOLD),
fn item_title(
title: &str,
graph_lane: Option<usize>,
activity_kind: Option<ActivityKind>,
max_width: usize,
) -> Line<'static> {
let mut spans = graph_lane.map_or_else(Vec::new, |node_lane| {
let mut spans = (0..node_lane)
.map(|lane| Span::styled("", Style::default().fg(lane_color(lane))))
.collect::<Vec<_>>();
spans.push(Span::styled(
"",
Style::default().fg(lane_color(node_lane)),
));
};
let mut spans = (0..node_lane)
.map(|lane| Span::styled("", Style::default().fg(lane_color(lane))))
.collect::<Vec<_>>();
spans
});
if let Some(kind) = activity_kind {
let (icon, color) = activity_icon(kind);
spans.push(Span::styled(icon, Style::default().fg(color)));
}
let prefix_width = spans.iter().map(Span::width).sum::<usize>();
spans.push(Span::styled(
"",
Style::default().fg(lane_color(node_lane)),
));
spans.push(Span::styled(
title.to_owned(),
truncate(title, max_width.saturating_sub(prefix_width)),
Style::default().add_modifier(Modifier::BOLD),
));
Line::from(spans)
}
fn truncate(value: &str, width: usize) -> String {
if UnicodeWidthStr::width(value) <= width {
return value.to_string();
}
if width == 0 {
return String::new();
}
let mut result = String::new();
let mut used = 0;
for character in value.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or_default();
if used + character_width >= width {
break;
}
result.push(character);
used += character_width;
}
result.push('…');
result
}
fn activity_icon(kind: ActivityKind) -> (&'static str, Color) {
match kind {
ActivityKind::Repository => ("", Color::Yellow),
ActivityKind::Issue => ("", Color::LightGreen),
ActivityKind::PullRequest => ("", Color::LightMagenta),
ActivityKind::Branch => ("", Color::LightCyan),
ActivityKind::Tag => ("", Color::LightYellow),
ActivityKind::Push => ("", Color::LightBlue),
ActivityKind::Release => ("", Color::LightRed),
}
}
fn lane_color(lane: usize) -> Color {
const COLORS: [Color; 6] = [
Color::LightRed,
@@ -447,7 +493,7 @@ mod tests {
#[test]
fn commit_graph_lanes_use_stable_distinct_colors() {
let title = item_title("Merge feature", Some(2));
let title = item_title("Merge feature", Some(2), None, 30);
assert_eq!(title.to_string(), "│ │ ● Merge feature");
assert_eq!(title.spans[0].style.fg, Some(lane_color(0)));
assert_eq!(title.spans[1].style.fg, Some(lane_color(1)));
@@ -473,6 +519,34 @@ mod tests {
assert_eq!(buffer[(6, 0)].fg, lane_color(2));
}
#[test]
fn home_activity_icons_are_colored_and_titles_fit_the_list() {
let issue = item_title(
"Notification settings opening issue · hugo/Gotcha",
None,
Some(ActivityKind::Issue),
24,
);
assert!(issue.to_string().ends_with('…'));
assert!(issue.width() <= 24);
assert_eq!(issue.spans[0].content, "");
assert_eq!(issue.spans[0].style.fg, Some(Color::LightGreen));
let kinds = [
ActivityKind::Repository,
ActivityKind::Issue,
ActivityKind::PullRequest,
ActivityKind::Branch,
ActivityKind::Tag,
ActivityKind::Push,
ActivityKind::Release,
];
let colors = kinds.map(activity_icon).map(|(_, color)| color);
for (index, color) in colors.iter().enumerate() {
assert!(!colors[..index].contains(color));
}
}
#[test]
fn previews_render_markdown_source_and_diffs_with_styles() {
let repository = gotcha_gitea::RepositoryId {