use crate::app::IDLE_SECS;
use crate::model::workspace::{FlatEntry, WorkspaceState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
pub fn render_tree(
frame: &mut Frame,
area: Rect,
workspace: &WorkspaceState,
flat: &[FlatEntry],
selected: usize,
scroll_offset: usize,
is_move_mode: bool,
status_message: Option<&str>,
active_tab: Option<&str>,
tab_names: &[String],
) {
let items: Vec<ListItem> = flat
.iter()
.map(|entry| match entry {
FlatEntry::Project { idx } => {
let p = &workspace.projects[*idx];
let icon = if p.expanded { "▼" } else { "▶" };
let count = if p.expanded {
String::new()
} else {
format!(" [{}]", p.worktrees.len())
};
let label = format!("{} {}{}", icon, p.name, count);
ListItem::new(label).style(Style::default().fg(Color::Cyan).bold())
}
FlatEntry::Worktree {
project_idx,
worktree_idx,
} => {
let p = &workspace.projects[*project_idx];
let wt = &p.worktrees[*worktree_idx];
let main_mark = if wt.is_main { "~ " } else { "" };
let expand_icon = if !wt.sessions.is_empty() {
if wt.expanded {
"▾"
} else {
"▸"
}
} else {
" "
};
let sess_badge = if !wt.sessions.is_empty() && !wt.expanded {
format!(" [{}]", wt.sessions.len())
} else {
String::new()
};
let proj_prefix = format!("{}-", p.name);
let short_name = wt.name.strip_prefix(&proj_prefix).unwrap_or(&wt.name);
let display = if let Some(alias) = &wt.alias {
format!("{} ({})", alias, short_name)
} else if wt.is_main {
wt.branch.clone()
} else {
short_name.to_string()
};
let dirty = wt
.git_info
.as_ref()
.map(|g| !g.modified_files.is_empty())
.unwrap_or(false);
let mut spans = vec![Span::raw(format!(
" {} {}{}",
expand_icon, main_mark, display
))];
if dirty {
spans.push(Span::styled("*", Style::default().fg(Color::Yellow)));
}
if let Some(gi) = &wt.git_info {
match (gi.behind, gi.ahead) {
(b, a) if b > 0 && a > 0 => spans.push(Span::styled(
format!(" ↓{}↑{}", b, a),
Style::default().fg(Color::Magenta),
)),
(b, _) if b > 0 => spans.push(Span::styled(
format!(" ↓{}", b),
Style::default().fg(Color::Red),
)),
(_, a) if a > 0 => spans.push(Span::styled(
format!(" ↑{}", a),
Style::default().fg(Color::Cyan),
)),
_ => {}
}
}
if !sess_badge.is_empty() {
spans.push(Span::raw(sess_badge));
}
ListItem::new(Line::from(spans)).style(Style::default().fg(Color::White))
}
FlatEntry::Session {
project_idx,
worktree_idx,
session_idx,
} => {
let sess = &workspace.projects[*project_idx].worktrees[*worktree_idx].sessions
[*session_idx];
let elapsed = sess.last_activity.map(|t| t.elapsed());
let active = elapsed.map(|e| e.as_secs() < IDLE_SECS).unwrap_or(false);
let (icon, icon_color) = session_icon(sess, active);
let idle_str = match elapsed {
Some(e) if e.as_secs() >= IDLE_SECS => format!(" {}", fmt_idle(e)),
_ => String::new(),
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled(icon, Style::default().fg(icon_color)),
Span::styled(
format!(" {}{}", sess.display_name, idle_str),
Style::default().fg(Color::Rgb(210, 200, 185)),
),
]);
ListItem::new(line)
}
})
.collect();
let mut list_state = ListState::default().with_offset(scroll_offset);
if !flat.is_empty() {
list_state.select(Some(selected.min(flat.len().saturating_sub(1))));
}
let highlight_bg = if is_move_mode { Color::Green } else { Color::Yellow };
let title_line: Line<'_> = {
let mut spans: Vec<Span<'_>> = vec![Span::styled(
" Workspaces ",
Style::default().bold(),
)];
if !tab_names.is_empty() {
spans.push(Span::raw("["));
for i in 0..=tab_names.len() {
if i > 0 {
spans.push(Span::raw("|"));
}
let (name, is_active) = if i == 0 {
("default", active_tab.is_none())
} else {
let t = tab_names[i - 1].as_str();
(t, active_tab == Some(t))
};
let display: String = if is_active {
name.to_string()
} else {
name.chars().take(2).collect()
};
let style = if is_active {
Style::default().fg(Color::Black).bg(Color::Yellow).bold()
} else {
Style::default()
};
spans.push(Span::styled(display, style));
}
spans.push(Span::raw("]"));
}
spans.push(Span::raw(if is_move_mode { " — MOVE " } else { " " }));
Line::from(spans)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title_line),
)
.highlight_style(Style::default().fg(Color::Black).bg(highlight_bg).bold())
.highlight_symbol("");
frame.render_stateful_widget(list, area, &mut list_state);
if let Some(msg) = status_message {
let msg_y = area.y + area.height.saturating_sub(1);
let inner_w = area.width.saturating_sub(2) as usize; let truncated = if msg.len() > inner_w {
&msg[..inner_w]
} else {
msg
};
let msg_rect = Rect::new(area.x + 1, msg_y, area.width.saturating_sub(2), 1);
let para = Paragraph::new(Span::styled(
format!(" {truncated}"),
Style::default().fg(Color::Yellow).bg(Color::Reset),
));
frame.render_widget(para, msg_rect);
}
}
fn session_icon(sess: &crate::model::workspace::SessionInfo, active: bool) -> (&'static str, Color) {
if sess.muted {
("⊘", Color::DarkGray)
} else if sess.has_activity {
("●", Color::Yellow)
} else if active {
("◉", Color::Green)
} else if sess.has_running_app && !sess.running_app_suppressed {
("●", Color::Yellow)
} else {
("○", Color::Gray)
}
}
fn fmt_idle(d: std::time::Duration) -> String {
let s = d.as_secs();
match s {
s if s < 60 => format!("{}s", s),
s if s < 3600 => format!("{}m", s / 60),
s => format!("{}h", s / 3600),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::workspace::SessionInfo;
use ratatui::style::Color;
fn sess(
muted: bool,
has_activity: bool,
has_running_app: bool,
running_app_suppressed: bool,
) -> SessionInfo {
SessionInfo {
name: String::new(),
display_name: String::new(),
has_activity,
pane_capture: None,
last_activity: None,
has_running_app,
is_running_wsx: false,
running_app_suppressed,
muted,
}
}
#[test]
fn active_output_is_green() {
let s = sess(false, false, true, false);
assert_eq!(session_icon(&s, true), ("◉", Color::Green));
}
#[test]
fn bell_overrides_active_green() {
let s = sess(false, true, false, false);
assert_eq!(session_icon(&s, true), ("●", Color::Yellow));
}
#[test]
fn running_quiet_is_yellow() {
let s = sess(false, false, true, false);
assert_eq!(session_icon(&s, false), ("●", Color::Yellow));
}
#[test]
fn running_suppressed_is_gray() {
let s = sess(false, false, true, true);
assert_eq!(session_icon(&s, false), ("○", Color::Gray));
}
#[test]
fn idle_no_app_is_gray() {
let s = sess(false, false, false, false);
assert_eq!(session_icon(&s, false), ("○", Color::Gray));
}
#[test]
fn muted_overrides_everything() {
let s = sess(true, true, true, false);
assert_eq!(session_icon(&s, true), ("⊘", Color::DarkGray));
}
}
pub fn compute_scroll(selected: usize, visible_height: usize, current_offset: usize) -> usize {
let up_pad = (visible_height / 4).max(1); let down_pad = (visible_height * 3 / 4).max(1); if selected < current_offset + up_pad {
selected.saturating_sub(up_pad - 1)
} else if selected >= current_offset + down_pad {
selected.saturating_sub(down_pad - 1)
} else {
current_offset
}
}