Add activity type icons to the TUI (#68)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -902,6 +902,7 @@ dependencies = [
|
|||||||
"syntect",
|
"syntect",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tui-markdown",
|
"tui-markdown",
|
||||||
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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"] }
|
syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tui-markdown = { version = "0.3", default-features = false }
|
tui-markdown = { version = "0.3", default-features = false }
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, BTreeSet},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,11 +103,43 @@ pub struct Item {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub graph_lane: Option<usize>,
|
pub graph_lane: Option<usize>,
|
||||||
|
pub activity_kind: Option<ActivityKind>,
|
||||||
pub meta: String,
|
pub meta: String,
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
pub target: Target,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Screen {
|
pub struct Screen {
|
||||||
pub kind: ScreenKind,
|
pub kind: ScreenKind,
|
||||||
@@ -180,6 +212,7 @@ pub struct App {
|
|||||||
pull_milestone: String,
|
pull_milestone: String,
|
||||||
pull_search: String,
|
pull_search: String,
|
||||||
branches: BTreeMap<String, String>,
|
branches: BTreeMap<String, String>,
|
||||||
|
activity_titles: BTreeMap<ActivityTitleKey, Option<String>>,
|
||||||
last_click: Option<(usize, Instant)>,
|
last_click: Option<(usize, Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +270,7 @@ impl App {
|
|||||||
pull_milestone: String::new(),
|
pull_milestone: String::new(),
|
||||||
pull_search: String::new(),
|
pull_search: String::new(),
|
||||||
branches: BTreeMap::new(),
|
branches: BTreeMap::new(),
|
||||||
|
activity_titles: BTreeMap::new(),
|
||||||
last_click: None,
|
last_click: None,
|
||||||
};
|
};
|
||||||
app.reload().await;
|
app.reload().await;
|
||||||
@@ -1091,7 +1125,7 @@ impl App {
|
|||||||
Ok(())
|
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 {
|
match kind {
|
||||||
ScreenKind::Servers => self.load_servers(),
|
ScreenKind::Servers => self.load_servers(),
|
||||||
ScreenKind::Home => self.load_home(page).await,
|
ScreenKind::Home => self.load_home(page).await,
|
||||||
@@ -1133,6 +1167,7 @@ impl App {
|
|||||||
name.clone()
|
name.clone()
|
||||||
},
|
},
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!("{} · {}", server.provider, server.url),
|
meta: format!("{} · {}", server.provider, server.url),
|
||||||
detail: format!(
|
detail: format!(
|
||||||
"Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••",
|
"Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••",
|
||||||
@@ -1144,12 +1179,13 @@ impl App {
|
|||||||
Ok(screen)
|
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
|
let data = self
|
||||||
.client
|
.client
|
||||||
.home(page as i32, self.activity_filter)
|
.home(page as i32, self.activity_filter)
|
||||||
.await
|
.await
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
|
self.load_activity_titles(&data.activities).await;
|
||||||
let contributions: i64 = data
|
let contributions: i64 = data
|
||||||
.heatmap
|
.heatmap
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1172,6 +1208,11 @@ 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 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 detail = activity_detail(&activity);
|
||||||
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 }) => {
|
||||||
@@ -1202,8 +1243,9 @@ impl App {
|
|||||||
"{}-{index}",
|
"{}-{index}",
|
||||||
activity.created.as_deref().unwrap_or_default()
|
activity.created.as_deref().unwrap_or_default()
|
||||||
),
|
),
|
||||||
title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()),
|
title,
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: Some(activity_kind),
|
||||||
meta: activity.created.unwrap_or_default(),
|
meta: activity.created.unwrap_or_default(),
|
||||||
detail,
|
detail,
|
||||||
target,
|
target,
|
||||||
@@ -1213,6 +1255,50 @@ impl App {
|
|||||||
Ok(screen)
|
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(
|
async fn load_repositories(
|
||||||
&self,
|
&self,
|
||||||
destination: RepositoryDestination,
|
destination: RepositoryDestination,
|
||||||
@@ -1257,6 +1343,7 @@ impl App {
|
|||||||
key: format!("{}/{}", id.owner, id.repository),
|
key: format!("{}/{}", id.owner, id.repository),
|
||||||
title: format!("{} {name}", if favorite { "★" } else { " " }),
|
title: format!("{} {name}", if favorite { "★" } else { " " }),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!(
|
meta: format!(
|
||||||
"{} · {} open · {}",
|
"{} · {} open · {}",
|
||||||
repository.language.as_deref().unwrap_or(""),
|
repository.language.as_deref().unwrap_or(""),
|
||||||
@@ -1357,6 +1444,7 @@ impl App {
|
|||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
),
|
),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: comment.updated_at.unwrap_or_default(),
|
meta: comment.updated_at.unwrap_or_default(),
|
||||||
detail: body.clone(),
|
detail: body.clone(),
|
||||||
target: id.map_or(Target::None, |id| {
|
target: id.map_or(Target::None, |id| {
|
||||||
@@ -1469,6 +1557,7 @@ impl App {
|
|||||||
key: path.clone(),
|
key: path.clone(),
|
||||||
title: format!("{} {name}", if directory { "▸" } else { " " }),
|
title: format!("{} {name}", if directory { "▸" } else { " " }),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: if directory {
|
meta: if directory {
|
||||||
"directory".into()
|
"directory".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -1573,6 +1662,7 @@ impl App {
|
|||||||
key: path.clone(),
|
key: path.clone(),
|
||||||
title: path.clone(),
|
title: path.clone(),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: file.status.unwrap_or_else(|| "modified".into()),
|
meta: file.status.unwrap_or_else(|| "modified".into()),
|
||||||
detail: format!(
|
detail: format!(
|
||||||
"+{} -{}",
|
"+{} -{}",
|
||||||
@@ -1598,6 +1688,7 @@ impl App {
|
|||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
),
|
),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: comment.updated_at.unwrap_or_default(),
|
meta: comment.updated_at.unwrap_or_default(),
|
||||||
detail: comment.body.unwrap_or_default(),
|
detail: comment.body.unwrap_or_default(),
|
||||||
target: Target::None,
|
target: Target::None,
|
||||||
@@ -1736,6 +1827,7 @@ fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option<Item> {
|
|||||||
key: number.to_string(),
|
key: number.to_string(),
|
||||||
title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")),
|
title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!(
|
meta: format!(
|
||||||
"{} · {} · {} comments",
|
"{} · {} · {} comments",
|
||||||
state,
|
state,
|
||||||
@@ -1759,6 +1851,7 @@ fn pull_item(issue: models::Issue) -> Option<Item> {
|
|||||||
issue.title.as_deref().unwrap_or("Untitled")
|
issue.title.as_deref().unwrap_or("Untitled")
|
||||||
),
|
),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!(
|
meta: format!(
|
||||||
"{} · {} comments",
|
"{} · {} comments",
|
||||||
issue.state.as_deref().unwrap_or("unknown"),
|
issue.state.as_deref().unwrap_or("unknown"),
|
||||||
@@ -1784,6 +1877,7 @@ fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Op
|
|||||||
.title
|
.title
|
||||||
.unwrap_or_else(|| "Untitled milestone".into()),
|
.unwrap_or_else(|| "Untitled milestone".into()),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!(
|
meta: format!(
|
||||||
"{} · {} open · {} closed",
|
"{} · {} open · {} closed",
|
||||||
state,
|
state,
|
||||||
@@ -1813,6 +1907,7 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option<Item
|
|||||||
key: sha.clone(),
|
key: sha.clone(),
|
||||||
title: message.lines().next().unwrap_or("Commit").into(),
|
title: message.lines().next().unwrap_or("Commit").into(),
|
||||||
graph_lane: node_lane,
|
graph_lane: node_lane,
|
||||||
|
activity_kind: None,
|
||||||
meta: format!(
|
meta: format!(
|
||||||
"{} · {}{}",
|
"{} · {}{}",
|
||||||
short_sha(&sha),
|
short_sha(&sha),
|
||||||
@@ -1839,6 +1934,7 @@ fn diff_files(diff: &str) -> Vec<Item> {
|
|||||||
key: path.into(),
|
key: path.into(),
|
||||||
title: path.into(),
|
title: path.into(),
|
||||||
graph_lane: None,
|
graph_lane: None,
|
||||||
|
activity_kind: None,
|
||||||
meta: "changed file".into(),
|
meta: "changed file".into(),
|
||||||
detail: diff_for_file(diff, path),
|
detail: diff_for_file(diff, path),
|
||||||
target: Target::Text(path.into(), 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)
|
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 {
|
fn activity_detail(activity: &models::Activity) -> String {
|
||||||
if let Some(body) = activity
|
if let Some(body) = activity
|
||||||
.comment
|
.comment
|
||||||
@@ -2071,4 +2235,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!detail.contains("\"Commits\""));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use syntect::{
|
|||||||
parsing::SyntaxSet,
|
parsing::SyntaxSet,
|
||||||
util::LinesWithEndings,
|
util::LinesWithEndings,
|
||||||
};
|
};
|
||||||
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{App, ScreenKind, Tab},
|
app::{ActivityKind, App, ScreenKind, Tab},
|
||||||
editor::Editor,
|
editor::Editor,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +121,12 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|item| {
|
.map(|item| {
|
||||||
ListItem::new(vec![
|
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(
|
Line::from(Span::styled(
|
||||||
item.meta.clone(),
|
item.meta.clone(),
|
||||||
Style::default().fg(Color::DarkGray),
|
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();
|
app.list_offset = state.offset();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_title(title: &str, graph_lane: Option<usize>) -> Line<'static> {
|
fn item_title(
|
||||||
let Some(node_lane) = graph_lane else {
|
title: &str,
|
||||||
return Line::from(Span::styled(
|
graph_lane: Option<usize>,
|
||||||
title.to_owned(),
|
activity_kind: Option<ActivityKind>,
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
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)),
|
||||||
));
|
));
|
||||||
};
|
spans
|
||||||
let mut spans = (0..node_lane)
|
});
|
||||||
.map(|lane| Span::styled("│ ", Style::default().fg(lane_color(lane))))
|
if let Some(kind) = activity_kind {
|
||||||
.collect::<Vec<_>>();
|
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(
|
spans.push(Span::styled(
|
||||||
"● ",
|
truncate(title, max_width.saturating_sub(prefix_width)),
|
||||||
Style::default().fg(lane_color(node_lane)),
|
|
||||||
));
|
|
||||||
spans.push(Span::styled(
|
|
||||||
title.to_owned(),
|
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
Line::from(spans)
|
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 {
|
fn lane_color(lane: usize) -> Color {
|
||||||
const COLORS: [Color; 6] = [
|
const COLORS: [Color; 6] = [
|
||||||
Color::LightRed,
|
Color::LightRed,
|
||||||
@@ -447,7 +493,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_graph_lanes_use_stable_distinct_colors() {
|
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.to_string(), "│ │ ● Merge feature");
|
||||||
assert_eq!(title.spans[0].style.fg, Some(lane_color(0)));
|
assert_eq!(title.spans[0].style.fg, Some(lane_color(0)));
|
||||||
assert_eq!(title.spans[1].style.fg, Some(lane_color(1)));
|
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));
|
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]
|
#[test]
|
||||||
fn previews_render_markdown_source_and_diffs_with_styles() {
|
fn previews_render_markdown_source_and_diffs_with_styles() {
|
||||||
let repository = gotcha_gitea::RepositoryId {
|
let repository = gotcha_gitea::RepositoryId {
|
||||||
|
|||||||
Reference in New Issue
Block a user