use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use super::tree::TreeState;
use super::view_model::{
AheadBehindState, FeedStatus, GlyphCue, RowEmphasis, SessionBadge, SessionSourceRow, Severity,
WorktreeRow, WorktreesViewModel,
};
pub fn draw_tree_pane(
frame: &mut Frame<'_>,
area: Rect,
view: &WorktreesViewModel,
tree: &mut TreeState,
focused: bool,
) {
let mut items: Vec<ListItem> = Vec::new();
if view.repos.is_empty() {
items.push(ListItem::new("No repositories open."));
}
for repo in &view.repos {
let mut header = repo.main_repo.clone();
if let Some(gh) = &repo.github {
header.push_str(&format!(" (github: {}/{})", gh.owner, gh.name));
}
if repo.polling_enabled {
header.push_str(" [polling]");
}
if let Some(tag) = &repo.row_color {
header.push_str(&format!(" ({tag})"));
}
header.push_str(&format!(" {}", repo.root.display()));
let header_line = Line::from(Span::styled(
header,
Style::default().add_modifier(Modifier::BOLD),
));
items.push(gutter_item(header_line, tree.marked.contains(&repo.root)));
for wt in &repo.worktrees {
items.push(gutter_item(
worktree_line(wt),
tree.marked.contains(&wt.path),
));
}
}
let mut state = ListState::default().with_offset(tree.offset);
if !items.is_empty() {
state.select(Some(tree.cursor.min(items.len() - 1)));
}
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title("WORKTREES"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(list, area, &mut state);
tree.offset = state.offset();
}
fn gutter_item(line: Line<'static>, marked: bool) -> ListItem<'static> {
let gutter = if marked { "▌ " } else { " " };
let mut spans = vec![Span::raw(gutter)];
spans.extend(line.spans);
ListItem::new(Line::from(spans))
}
fn worktree_line(wt: &WorktreeRow) -> Line<'static> {
let glyph = match wt.glyph_cue() {
GlyphCue::Here => '*',
GlyphCue::Pushing | GlyphCue::Rebasing => '~',
GlyphCue::Operation => '!',
GlyphCue::Open => 'o',
GlyphCue::Closed => '.',
};
let main_marker = if wt.is_main { '*' } else { ' ' };
let branch = wt
.branch
.clone()
.unwrap_or_else(|| "(detached)".to_string());
let mut fields = Vec::new();
match wt.ahead_behind {
AheadBehindState::Known {
ahead,
behind,
main_behind,
} => {
let mut s = format!("+{ahead} -{behind}");
if let Some(mb) = main_behind {
s.push_str(&format!(" main-{mb}"));
}
fields.push(s);
}
AheadBehindState::Loading => fields.push("...".to_string()),
AheadBehindState::Unknown | AheadBehindState::Unavailable => {}
}
if let Some(pr) = &wt.pr {
let draft = if pr.is_draft { " draft" } else { "" };
fields.push(format!("#{}{draft}", pr.number));
}
if !wt.sessions.is_empty() {
fields.push(sessions_summary(&wt.sessions));
}
let severity_marker = match wt.badge_severity() {
Severity::Red => " [!]",
Severity::Yellow => " [~]",
Severity::Green => " [ok]",
Severity::Muted => "",
};
let mut text = format!(" {main_marker}{glyph} {branch}");
if !fields.is_empty() {
text.push_str(" ");
text.push_str(&fields.join(" "));
}
text.push_str(" ");
text.push_str(&wt.path.display().to_string());
text.push_str(severity_marker);
let color = match wt.emphasis() {
RowEmphasis::Operation => Color::Yellow,
RowEmphasis::UserTag(tag) => color_for_tag(&tag),
RowEmphasis::Open | RowEmphasis::Default => match wt.badge_severity() {
Severity::Red => Color::Red,
Severity::Yellow => Color::Yellow,
Severity::Green => Color::Green,
Severity::Muted if wt.open => Color::Green,
Severity::Muted => Color::Reset,
},
};
Line::from(Span::styled(text, Style::default().fg(color)))
}
fn color_for_tag(tag: &str) -> Color {
match tag {
"charts.red" | "terminal.ansiRed" => Color::Red,
"charts.orange" => Color::Rgb(0xff, 0xa5, 0x00),
"charts.yellow" | "terminal.ansiYellow" => Color::Yellow,
"charts.green" | "terminal.ansiGreen" => Color::Green,
"charts.blue" | "terminal.ansiBlue" => Color::Blue,
"charts.purple" | "terminal.ansiMagenta" => Color::Magenta,
"terminal.ansiCyan" => Color::Cyan,
"charts.foreground" | "descriptionForeground" => Color::Gray,
_ => Color::Reset,
}
}
fn sessions_summary(sessions: &[SessionBadge]) -> String {
let Some(latest) = sessions.iter().max_by_key(|s| s.last_seen) else {
return String::new();
};
let source = match &latest.source {
SessionSourceRow::Terminal => "terminal",
SessionSourceRow::VsCode { .. } => "vscode",
};
let model = latest.model.as_deref().unwrap_or("?");
format!("{} session(s) ({model}, {source})", sessions.len())
}
pub fn draw_status_bar(
frame: &mut Frame<'_>,
area: Rect,
view: &WorktreesViewModel,
tree: &TreeState,
hint: &str,
) {
let closed = if view.show_closed { "shown" } else { "hidden" };
let marked = if tree.marked.is_empty() {
String::new()
} else {
format!("{} marked ", tree.marked.len())
};
let status = format!(
"{marked}worktrees: {} sessions: {} closed {closed} {hint}",
feed_status_label(view.worktrees_status),
feed_status_label(view.sessions_status),
);
frame.render_widget(Paragraph::new(status), area);
}
fn feed_status_label(status: FeedStatus) -> String {
match status {
FeedStatus::Connecting => "connecting".to_string(),
FeedStatus::Live => "live".to_string(),
FeedStatus::Reconnecting { attempt, retry_in } => {
format!(
"reconnecting (attempt {attempt}, retry in {}s)",
retry_in.as_secs()
)
}
FeedStatus::Polling => "polling (daemon predates live updates)".to_string(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::path::PathBuf;
use crate::pr_status::PrCheckState;
use super::super::view_model::PrBadgeRow;
use super::*;
fn worktree_row() -> WorktreeRow {
WorktreeRow {
path: PathBuf::from("/repo/wt"),
branch: None,
head_sha: None,
upstream_sha: None,
is_main: false,
open: false,
window_key: None,
pr: None,
pr_none: false,
operation: None,
rebasing: false,
pushing: false,
ahead_behind: AheadBehindState::Unknown,
sessions: Vec::new(),
row_color: None,
here: false,
}
}
fn line_color(wt: &WorktreeRow) -> Color {
match worktree_line(wt).spans.first() {
Some(span) => span.style.fg.unwrap_or(Color::Reset),
None => Color::Reset,
}
}
#[test]
fn color_for_tag_maps_every_known_row_color() {
for tag in super::super::row_colors::KNOWN_ROW_COLORS {
assert_ne!(color_for_tag(tag), Color::Reset, "tag: {tag}");
}
}
#[test]
fn color_for_tag_falls_back_to_reset_for_an_unrecognized_id() {
assert_eq!(color_for_tag("not-a-real-color"), Color::Reset);
}
#[test]
fn a_user_row_tag_is_rendered_in_its_mapped_color() {
let mut wt = worktree_row();
wt.row_color = Some("charts.blue".to_string());
assert_eq!(line_color(&wt), Color::Blue);
}
#[test]
fn failing_pr_checks_color_the_row_red_even_with_no_operation_or_tag() {
let mut wt = worktree_row();
wt.pr = Some(PrBadgeRow {
number: 1,
is_draft: false,
checks: PrCheckState::Failure,
url: String::new(),
});
assert_eq!(line_color(&wt), Color::Red);
}
#[test]
fn an_in_flight_operation_still_wins_over_severity() {
let mut wt = worktree_row();
wt.rebasing = true;
wt.pr = Some(PrBadgeRow {
number: 1,
is_draft: false,
checks: PrCheckState::Failure,
url: String::new(),
});
assert_eq!(line_color(&wt), Color::Yellow);
}
#[test]
fn open_with_no_severity_or_tag_stays_green() {
let mut wt = worktree_row();
wt.open = true;
assert_eq!(line_color(&wt), Color::Green);
}
fn buffer_text(terminal: &ratatui::Terminal<ratatui::backend::TestBackend>) -> String {
terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn sample_view() -> WorktreesViewModel {
use super::super::view_model::{GithubIdentity, RepoRow};
let mut open = worktree_row();
open.path = PathBuf::from("/repo/wt-open");
open.branch = Some("feature/x".to_string());
open.open = true;
open.ahead_behind = AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: Some(3),
};
open.pr = Some(PrBadgeRow {
number: 42,
is_draft: true,
checks: PrCheckState::Pending,
url: String::new(),
});
let mut main = worktree_row();
main.path = PathBuf::from("/repo");
main.is_main = true;
main.branch = Some("main".to_string());
main.ahead_behind = AheadBehindState::Loading;
WorktreesViewModel {
repos: vec![RepoRow {
main_repo: "repo".to_string(),
github: Some(GithubIdentity {
owner: "acme".to_string(),
name: "repo".to_string(),
}),
root: PathBuf::from("/repo"),
polling_enabled: true,
row_color: Some("charts.blue".to_string()),
worktrees: vec![main, open],
}],
show_closed: false,
..Default::default()
}
}
#[test]
fn draw_tree_pane_renders_rows_gutter_and_focus_border() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let view = sample_view();
let mut tree = TreeState {
cursor: 2,
marked: std::iter::once(PathBuf::from("/repo/wt-open")).collect(),
offset: 0,
};
for focused in [true, false] {
let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap();
terminal
.draw(|frame| draw_tree_pane(frame, frame.area(), &view, &mut tree, focused))
.unwrap();
let text = buffer_text(&terminal);
assert!(text.contains("WORKTREES"));
assert!(text.contains("repo (github: acme/repo) [polling] (charts.blue)"));
assert!(text.contains("feature/x +2 -1 main-3 #42 draft"));
assert!(text.contains("▌"), "the marked row has a gutter");
assert!(text.contains("*. main ..."), "main marker + loading state");
}
}
#[test]
fn draw_tree_pane_with_no_repos_says_so() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
let view = WorktreesViewModel::default();
let mut tree = TreeState::default();
terminal
.draw(|frame| draw_tree_pane(frame, frame.area(), &view, &mut tree, true))
.unwrap();
assert!(buffer_text(&terminal).contains("No repositories open."));
}
#[test]
fn draw_tree_pane_reports_the_offset_the_list_scrolled_to() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let view = sample_view();
let mut tree = TreeState {
cursor: 2,
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(60, 3)).unwrap();
terminal
.draw(|frame| draw_tree_pane(frame, frame.area(), &view, &mut tree, true))
.unwrap();
assert_eq!(tree.offset, 2);
assert!(buffer_text(&terminal).contains("feature/x"));
tree.cursor = 0;
terminal
.draw(|frame| draw_tree_pane(frame, frame.area(), &view, &mut tree, true))
.unwrap();
assert_eq!(tree.offset, 0);
}
#[test]
fn draw_status_bar_shows_feed_states_marks_and_the_hint() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut view = sample_view();
view.worktrees_status = FeedStatus::Live;
view.sessions_status = FeedStatus::Reconnecting {
attempt: 2,
retry_in: std::time::Duration::from_secs(3),
};
let tree = TreeState {
cursor: 0,
marked: std::iter::once(PathBuf::from("/repo")).collect(),
offset: 0,
};
let mut terminal = Terminal::new(TestBackend::new(120, 1)).unwrap();
terminal
.draw(|frame| draw_status_bar(frame, frame.area(), &view, &tree, "press a"))
.unwrap();
let text = buffer_text(&terminal);
assert!(text.contains("1 marked"));
assert!(text.contains("worktrees: live"));
assert!(text.contains("reconnecting (attempt 2, retry in 3s)"));
assert!(text.contains("closed hidden"));
assert!(text.contains("press a"));
view.sessions_status = FeedStatus::Polling;
view.worktrees_status = FeedStatus::Connecting;
let mut terminal = Terminal::new(TestBackend::new(120, 1)).unwrap();
terminal
.draw(|frame| draw_status_bar(frame, frame.area(), &view, &TreeState::default(), ""))
.unwrap();
let text = buffer_text(&terminal);
assert!(text.contains("polling (daemon predates live updates)"));
assert!(text.contains("worktrees: connecting"));
}
#[test]
fn default_row_with_no_signal_is_reset() {
assert_eq!(line_color(&worktree_row()), Color::Reset);
}
}