use std::path::PathBuf;
use ratatui::{
style::{Color, Style},
text::{Line, Span},
widgets::ListItem,
};
use crate::tui::components::ListItemRenderable;
use crate::tui::tabs::StatusIndicator;
use crate::worktree::GitWorktreeStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeItem {
pub display_name: String,
pub branch: String,
pub path: PathBuf,
pub status: GitWorktreeStatus,
}
impl WorktreeItem {
#[must_use]
pub fn new(branch: impl Into<String>, path: PathBuf, status: GitWorktreeStatus) -> Self {
let display_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
Self {
display_name,
branch: branch.into(),
path,
status,
}
}
pub(crate) fn status_display(&self) -> Option<String> {
let s = &self.status;
if s.detached {
return Some("⊘".to_string());
}
if s.no_upstream {
return Some("?".to_string());
}
let mut parts = Vec::new();
if s.dirty {
parts.push("*".to_string());
}
if s.ahead > 0 {
parts.push(format!("↑{}", s.ahead));
}
if s.behind > 0 {
parts.push(format!("↓{}", s.behind));
}
if parts.is_empty() {
None } else {
Some(parts.join(""))
}
}
pub(crate) fn status_color(&self) -> Color {
let s = &self.status;
if s.dirty {
Color::Yellow
} else if s.behind > 0 {
Color::Red
} else if s.ahead > 0 {
Color::Cyan
} else {
Color::Green
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueItem {
pub number: u32,
pub title: String,
pub labels: String,
}
impl IssueItem {
#[must_use]
pub fn new(number: u32, title: impl Into<String>, labels: impl Into<String>) -> Self {
Self {
number,
title: title.into(),
labels: labels.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct SessionItem {
pub name: String,
pub status: StatusIndicator,
pub branch: Option<String>,
pub is_pending: bool,
}
impl SessionItem {
#[must_use]
pub fn new(name: impl Into<String>, status: StatusIndicator) -> Self {
Self {
name: name.into(),
status,
branch: None,
is_pending: false,
}
}
#[must_use]
pub fn branch(mut self, branch: Option<String>) -> Self {
self.branch = branch;
self
}
#[must_use]
pub fn pending(mut self, is_pending: bool) -> Self {
self.is_pending = is_pending;
self
}
}
impl ListItemRenderable for SessionItem {
fn to_list_item(&self) -> ListItem<'_> {
let indicator = Span::styled(
format!("{} ", self.status.symbol()),
Style::default().fg(self.status.color()),
);
let name = Span::raw(&self.name);
let mut spans = vec![indicator, name];
if let Some(branch) = &self.branch {
spans.push(Span::styled(
format!(" ({branch})"),
Style::default().fg(Color::Gray),
));
}
if self.is_pending {
spans.push(Span::styled(" [!]", Style::default().fg(Color::Red)));
}
ListItem::new(Line::from(spans))
}
}
impl ListItemRenderable for WorktreeItem {
fn to_list_item(&self) -> ListItem<'_> {
let indicator_color = self.status_color();
let indicator = Span::styled("◆ ", Style::default().fg(indicator_color));
let display_name = Span::styled(
&self.display_name[..8.min(self.display_name.len())],
Style::default().fg(Color::Yellow),
);
let branch = Span::styled(
format!(" ({})", self.branch),
Style::default().fg(Color::Gray),
);
let mut spans = vec![indicator, display_name, branch];
if let Some(status_str) = self.status_display() {
spans.push(Span::styled(
format!(" [{status_str}]"),
Style::default().fg(indicator_color),
));
}
ListItem::new(Line::from(spans))
}
}
impl ListItemRenderable for IssueItem {
fn to_list_item(&self) -> ListItem<'_> {
let number = Span::styled(
format!("#{} ", self.number),
Style::default().fg(Color::Yellow),
);
let title = Span::raw(&self.title);
let mut spans = vec![number, title];
if !self.labels.is_empty() {
spans.push(Span::styled(
format!(" {}", self.labels),
Style::default().fg(Color::Magenta),
));
}
ListItem::new(Line::from(spans))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn worktree_item_new() {
let item = WorktreeItem::new(
"tazuna/abc123",
PathBuf::from("/path/to/worktree"),
GitWorktreeStatus::default(),
);
assert_eq!(item.display_name, "worktree");
assert_eq!(item.branch, "tazuna/abc123");
assert_eq!(item.path, PathBuf::from("/path/to/worktree"));
assert!(!item.status.dirty);
}
#[test]
fn worktree_item_status_display_clean() {
let item = WorktreeItem::new("main", PathBuf::from("/path"), GitWorktreeStatus::default());
assert_eq!(item.status_display(), None);
}
#[test]
fn worktree_item_status_display_dirty() {
let item = WorktreeItem::new(
"main",
PathBuf::from("/path"),
GitWorktreeStatus {
dirty: true,
..Default::default()
},
);
assert_eq!(item.status_display(), Some("*".to_string()));
}
#[test]
fn worktree_item_status_display_ahead_behind() {
let item = WorktreeItem::new(
"main",
PathBuf::from("/path"),
GitWorktreeStatus {
ahead: 3,
behind: 2,
..Default::default()
},
);
assert_eq!(item.status_display(), Some("↑3↓2".to_string()));
}
#[test]
fn worktree_item_status_display_detached() {
let item = WorktreeItem::new(
"HEAD",
PathBuf::from("/path"),
GitWorktreeStatus {
detached: true,
..Default::default()
},
);
assert_eq!(item.status_display(), Some("⊘".to_string()));
}
#[test]
fn worktree_item_status_display_no_upstream() {
let item = WorktreeItem::new(
"main",
PathBuf::from("/path"),
GitWorktreeStatus {
no_upstream: true,
..Default::default()
},
);
assert_eq!(item.status_display(), Some("?".to_string()));
}
#[rstest]
#[case(GitWorktreeStatus { dirty: true, ..Default::default() }, Color::Yellow)]
#[case(GitWorktreeStatus { behind: 2, ..Default::default() }, Color::Red)]
#[case(GitWorktreeStatus { ahead: 1, ..Default::default() }, Color::Cyan)]
#[case(GitWorktreeStatus::default(), Color::Green)]
fn worktree_item_status_color(#[case] status: GitWorktreeStatus, #[case] expected: Color) {
let item = WorktreeItem::new("branch", PathBuf::from("/path"), status);
assert_eq!(item.status_color(), expected);
}
#[test]
fn issue_item_new() {
let item = IssueItem::new(42, "Test issue", "bug, feature");
assert_eq!(item.number, 42);
assert_eq!(item.title, "Test issue");
assert_eq!(item.labels, "bug, feature");
}
#[test]
fn issue_item_new_empty_labels() {
let item = IssueItem::new(1, "Simple", "");
assert_eq!(item.number, 1);
assert_eq!(item.title, "Simple");
assert!(item.labels.is_empty());
}
#[test]
fn test_session_item_with_branch() {
let item = SessionItem::new("my-session", StatusIndicator::Running)
.branch(Some("feature/auth".to_string()));
assert_eq!(item.name, "my-session");
assert_eq!(item.branch, Some("feature/auth".to_string()));
}
#[test]
fn test_session_item_without_branch() {
let item = SessionItem::new("my-session", StatusIndicator::Running);
assert_eq!(item.name, "my-session");
assert_eq!(item.branch, None);
}
#[test]
fn test_session_item_pending() {
let item = SessionItem::new("my-session", StatusIndicator::Running).pending(true);
assert!(item.is_pending);
}
#[test]
fn test_session_item_not_pending_by_default() {
let item = SessionItem::new("my-session", StatusIndicator::Running);
assert!(!item.is_pending);
}
}