diff --git a/Cargo.lock b/Cargo.lock index 8de5a03..63637f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "syntect", "tokio", "tui-markdown", + "unicode-width", ] [[package]] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index fdc4afd..6894338 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0eda38f..c53349a 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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, + pub activity_kind: Option, 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, + activity_titles: BTreeMap>, 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 { + async fn load_screen(&mut self, kind: ScreenKind, page: u32) -> Result { 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 { + async fn load_home(&mut self, page: u32) -> Result { 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 { 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 { 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 Vec { 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 { + 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" + ); + } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 6b74d3d..ddc1e33 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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) -> 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, + activity_kind: Option, + 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::>(); + 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::>(); + 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::(); 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 {