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::glyph::{self, Glyph, GlyphMode};
use super::tree::TreeState;
use super::view_model::{
AheadBehindState, FeedStatus, GlyphCue, RowEmphasis, SessionBadge, SessionSourceRow, Severity,
WorktreeRow, WorktreesViewModel,
};
use crate::pr_status::PrCheckState;
use crate::sessions::SessionState;
pub fn draw_tree_pane(
frame: &mut Frame<'_>,
area: Rect,
view: &WorktreesViewModel,
tree: &mut TreeState,
focused: bool,
glyphs: GlyphMode,
) {
let mut items: Vec<ListItem> = Vec::new();
if view.repos.is_empty() {
items.push(ListItem::new("No repositories open."));
}
let branch_width = usize::from(area.width.saturating_sub(FIXED_COLUMNS)).clamp(8, 32);
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),
glyphs,
));
for wt in &repo.worktrees {
items.push(gutter_item(
worktree_line(wt, glyphs, branch_width),
tree.marked.contains(&wt.path),
glyphs,
));
}
}
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, glyphs: GlyphMode) -> ListItem<'static> {
let marker = if marked {
Glyph::MarkedGutter
} else {
Glyph::UnmarkedGutter
};
let mut spans = vec![Span::raw(format!("{} ", marker.as_str(glyphs)))];
spans.extend(line.spans);
ListItem::new(Line::from(spans))
}
const FIXED_COLUMNS: u16 = 34;
fn worktree_line(wt: &WorktreeRow, glyphs: GlyphMode, branch_width: usize) -> Line<'static> {
let glyph = match wt.glyph_cue() {
GlyphCue::Here => Glyph::Here,
GlyphCue::Pushing | GlyphCue::Rebasing => Glyph::InFlight,
GlyphCue::Operation => Glyph::Operation,
GlyphCue::Open => Glyph::Open,
GlyphCue::Closed => Glyph::Closed,
}
.as_str(glyphs);
let main_marker = if wt.is_main {
Glyph::MainMarker
} else {
Glyph::NotMain
}
.as_str(glyphs);
let branch = glyph::truncate_middle(
wt.branch.as_deref().unwrap_or("(detached)"),
branch_width,
glyphs,
);
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::MainOnly { main_behind } => fields.push(format!("main-{main_behind}")),
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 badges = format!("{}{}", check_glyph(wt, glyphs), session_glyph(wt, glyphs));
let mut text = format!(" {main_marker}{glyph} {badges} {branch:<branch_width$}");
if !fields.is_empty() {
text.push_str(" ");
text.push_str(&fields.join(" "));
}
text.push_str(" ");
text.push_str(&wt.path.display().to_string());
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 check_glyph(wt: &WorktreeRow, glyphs: GlyphMode) -> &'static str {
let Some(pr) = &wt.pr else {
return " ";
};
match pr.checks {
PrCheckState::Success => Glyph::ChecksPass,
PrCheckState::Failure => Glyph::ChecksFail,
PrCheckState::Pending => Glyph::ChecksPending,
PrCheckState::None => return " ",
}
.as_str(glyphs)
}
fn session_glyph(wt: &WorktreeRow, glyphs: GlyphMode) -> &'static str {
let mut cue: Option<Glyph> = None;
for session in &wt.sessions {
let candidate = match session.state {
SessionState::WaitingForInput | SessionState::WaitingForPermission => {
Glyph::SessionWaiting
}
SessionState::Working => Glyph::SessionWorking,
SessionState::Starting | SessionState::Idle => Glyph::SessionIdle,
SessionState::Ended => continue,
};
let rank = |g: Glyph| match g {
Glyph::SessionWaiting => 3,
Glyph::SessionWorking => 2,
_ => 1,
};
if cue.is_none_or(|current| rank(candidate) > rank(current)) {
cue = Some(candidate);
}
}
cue.map_or(" ", |g| g.as_str(glyphs))
}
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(),
latest.agent
)
}
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, GlyphMode::Unicode, 24).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);
}
fn line_text(wt: &WorktreeRow) -> String {
worktree_line(wt, GlyphMode::Unicode, 24)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn a_main_only_row_renders_main_n_without_the_counts() {
let mut wt = worktree_row();
wt.ahead_behind = AheadBehindState::MainOnly { main_behind: 4 };
let text = line_text(&wt);
assert!(text.contains("main-4"), "{text}");
assert!(!text.contains('+'), "{text}");
assert!(!text.contains(" -"), "{text}");
}
#[test]
fn a_main_only_row_level_with_the_default_branch_still_renders_main_0() {
let mut wt = worktree_row();
wt.ahead_behind = AheadBehindState::MainOnly { main_behind: 0 };
assert!(line_text(&wt).contains("main-0"));
}
#[test]
fn a_known_row_still_renders_both_counts_before_main_n() {
let mut wt = worktree_row();
wt.ahead_behind = AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: Some(3),
};
assert!(line_text(&wt).contains("+2 -1 main-3"));
}
#[test]
fn unknown_and_unavailable_rows_render_no_divergence_field() {
for state in [AheadBehindState::Unknown, AheadBehindState::Unavailable] {
let mut wt = worktree_row();
wt.ahead_behind = state;
let text = line_text(&wt);
assert!(!text.contains("main-") && !text.contains('+'), "{text}");
}
}
#[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,
GlyphMode::Unicode,
);
})
.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"));
assert!(text.contains("+2 -1 main-3 #42 draft"));
assert!(
text.contains('\u{2502}'),
"the marked row has a gutter: {text}"
);
assert!(
text.contains('\u{2026}'),
"the pending-checks badge is the ellipsis glyph: {text}"
);
assert!(text.contains("main"), "the main worktree's branch");
assert!(text.contains("..."), "its loading divergence 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,
GlyphMode::Unicode,
);
})
.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,
GlyphMode::Unicode,
);
})
.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,
GlyphMode::Unicode,
);
})
.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 the_badge_column_shows_checks_then_session_state() {
use crate::sessions::SessionState;
let mut wt = worktree_row();
assert_eq!(check_glyph(&wt, GlyphMode::Unicode), " ");
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), " ");
wt.pr = Some(PrBadgeRow {
number: 1,
is_draft: false,
checks: PrCheckState::Failure,
url: String::new(),
});
assert_eq!(check_glyph(&wt, GlyphMode::Unicode), "\u{2717}");
assert_eq!(check_glyph(&wt, GlyphMode::Ascii), "x");
let session = |state| SessionBadge {
session_id: "s".to_string(),
state,
source: SessionSourceRow::Terminal,
model: None,
agent: "Claude".to_string(),
last_seen: chrono::Utc::now(),
};
wt.sessions = vec![
session(SessionState::Idle),
session(SessionState::WaitingForPermission),
session(SessionState::Idle),
];
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), "!");
wt.sessions = vec![session(SessionState::Idle), session(SessionState::Working)];
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), "\u{25cf}");
wt.sessions = vec![session(SessionState::Idle)];
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), "\u{00b7}");
wt.sessions = vec![session(SessionState::Starting)];
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), "\u{00b7}");
wt.sessions = vec![session(SessionState::Ended)];
assert_eq!(session_glyph(&wt, GlyphMode::Unicode), " ");
}
#[test]
fn the_sessions_summary_names_the_latest_sessions_agent() {
let now = chrono::Utc::now();
let badge = |agent: &str, model: Option<&str>, age: i64| SessionBadge {
session_id: "s".to_string(),
state: SessionState::Working,
source: SessionSourceRow::Terminal,
model: model.map(str::to_string),
agent: agent.to_string(),
last_seen: now - chrono::Duration::seconds(age),
};
assert_eq!(
sessions_summary(&[
badge("Claude", Some("claude-opus-4-8"), 30),
badge("Codex", Some("gpt-5.5"), 1),
]),
"2 session(s) (Codex gpt-5.5, terminal)"
);
assert_eq!(
sessions_summary(&[badge("pi", None, 0)]),
"1 session(s) (pi ?, terminal)"
);
assert_eq!(sessions_summary(&[]), "");
}
#[test]
fn a_long_branch_is_elided_and_the_row_stays_column_aligned() {
use unicode_width::UnicodeWidthStr as _;
let mut short = worktree_row();
short.branch = Some("main".to_string());
let mut long = worktree_row();
long.branch = Some("issue-1585-worktrees-ui-phase-4c-and-then-some".to_string());
let width = 20;
let a = worktree_line(&short, GlyphMode::Unicode, width);
let b = worktree_line(&long, GlyphMode::Unicode, width);
let text_a: String = a.spans.iter().map(|s| s.content.as_ref()).collect();
let text_b: String = b.spans.iter().map(|s| s.content.as_ref()).collect();
let at_a = text_a.find("/repo/wt").expect("a path in the short row");
let at_b = text_b.find("/repo/wt").expect("a path in the long row");
assert_eq!(
text_a[..at_a].width(),
text_b[..at_b].width(),
"paths must start at the same column:\n{text_a}\n{text_b}"
);
assert!(text_b.contains('\u{2026}'), "the long branch is elided");
}
#[test]
fn ascii_mode_renders_no_non_ascii_anywhere_in_a_row() {
let mut wt = worktree_row();
wt.branch = Some("issue-1585-worktrees-ui-phase-4c-and-then-some".to_string());
wt.is_main = true;
wt.open = true;
wt.pr = Some(PrBadgeRow {
number: 7,
is_draft: true,
checks: PrCheckState::Pending,
url: String::new(),
});
let line = worktree_line(&wt, GlyphMode::Ascii, 16);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.is_ascii(), "ascii mode emitted non-ascii: {text:?}");
let item = gutter_item(line, true, GlyphMode::Ascii);
let rendered: String = format!("{item:?}");
assert!(!rendered.contains('\u{2502}'));
}
#[test]
fn default_row_with_no_signal_is_reset() {
assert_eq!(line_color(&worktree_row()), Color::Reset);
}
}