use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::{App, RailSection};
use crate::focus::Focus;
use crate::git::rail::GitRailHit;
use crate::git::status::FileState;
use crate::ui::{hover_help, icons, theme};
const CHEVRON_OPEN: &str = "\u{F47C}"; const CHEVRON_CLOSED: &str = "\u{F460}";
const BRANCH_LIST_CAP: usize = 8;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let rail_bg = theme::cur().bg_darker;
frame.render_widget(
ratatui::widgets::Block::default().style(Style::default().bg(rail_bg)),
area,
);
app.rects.tree = None;
app.rects.tree_toggle = None;
app.rects.git_section_toggle = None;
app.rects.git_rail_rows.clear();
app.rects.extra_workspace_bodies.clear();
app.rects.extra_workspace_toggles.clear();
app.rects.tree_icon_buttons.clear();
app.rects.integration_section_toggle = None;
if area.height == 0 || area.width == 0 {
return;
}
let nerd = !app.config.ui.ascii_icons;
let width = area.width as usize;
if area.height < 2 {
return;
}
let hover_help_area: Option<Rect> = None;
let git_needed = 0u16;
let _integration_needed = 0u16;
let integration_height = 0u16;
let git_height = 0u16;
let git_overflow_rows: u16 = if git_height < git_needed {
git_needed.saturating_sub(git_height)
} else {
0
};
app.rects.integration_section_h = integration_height;
app.rects.git_section_h = git_height;
let git_bottom_pad: u16 = 1;
let git_top_y = area.y + area.height - git_height - git_bottom_pad;
let integration_top_y = git_top_y.saturating_sub(integration_height + 1); let ws_end_y = if integration_height > 0 {
integration_top_y.saturating_sub(1)
} else {
git_top_y.saturating_sub(1)
};
app.rects.workspace_picker_chevron = None;
let ws_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: ws_end_y.saturating_sub(area.y),
};
enum Slot {
Primary,
Extra(usize),
}
let mut slots: Vec<(usize, Slot)> = Vec::with_capacity(app.extra_workspaces.len() + 1);
slots.push((app.primary_position, Slot::Primary));
for (i, w) in app.extra_workspaces.iter().enumerate() {
slots.push((w.position, Slot::Extra(i)));
}
slots.sort_by_key(|(p, _)| *p);
let mut next_y = area.y;
for (slot_idx, (_pos, slot)) in slots.iter().enumerate() {
if next_y >= ws_end_y {
break;
}
if slot_idx > 0 {
if next_y + 1 >= ws_end_y {
break;
}
next_y += 1;
}
match slot {
Slot::Primary => {
next_y = draw_primary_workspace_section(frame, app, ws_area, next_y, nerd, rail_bg);
}
Slot::Extra(i) => {
next_y = draw_extra_workspace_section(frame, app, ws_area, next_y, *i, nerd);
}
}
}
if next_y + 1 < ws_end_y {
draw_add_repo_row(frame, app, area, next_y + 1, nerd, rail_bg);
}
if integration_height > 0 {
draw_integration_section(
frame,
app,
area,
integration_top_y,
integration_height,
nerd,
rail_bg,
);
}
if git_height == 0 {
hover_help_finish(frame, app, hover_help_area);
return;
}
let git_header_y = git_top_y;
if git_header_y >= area.y + area.height {
hover_help_finish(frame, app, hover_help_area);
return;
}
let triangle = app.config.ui.expand_indicator == "triangle";
let chev = section_chev_with_pref(app.git_section_expanded, nerd, triangle);
let multi_repo_chip = if app.repos.len() > 1 {
app.repos
.get(app.active_repo)
.map(|r| format!(" · {}", r.name))
.unwrap_or_default()
} else {
String::new()
};
let chev_str = format!(" {chev} ");
let label_str = format!("GIT{multi_repo_chip}");
let header_used = chev_str.chars().count() + label_str.chars().count();
let t = theme::cur();
type ChipSpec = (
&'static str,
&'static str,
crate::GitRailHeaderAction,
ratatui::style::Color,
);
let chips_full: [ChipSpec; 6] = [
(
crate::ui::refresh_glyph::NERD,
crate::ui::refresh_glyph::ASCII,
crate::GitRailHeaderAction::Fetch,
t.cyan,
),
("\u{EB40}", "↓", crate::GitRailHeaderAction::Pull, t.green),
("\u{EB41}", "↑", crate::GitRailHeaderAction::Push, t.blue),
(
"\u{EA60}",
"+",
crate::GitRailHeaderAction::StageAll,
t.green,
),
(
"\u{F012C}",
"✓",
crate::GitRailHeaderAction::Commit,
t.green,
),
(
"\u{F062C}",
"⎇",
crate::GitRailHeaderAction::Graph,
t.yellow,
),
];
let chip_w = if nerd { 4usize } else { 3usize };
let min_separation = 1usize;
let chip_count = {
let mut n = chips_full.len();
while n > 0 && header_used + min_separation + n * chip_w > width {
n -= 1;
}
n
};
let chips_used = chip_count * chip_w;
let pad_between = width.saturating_sub(header_used + chips_used);
app.rects.rail_git_header_buttons.clear();
app.rects.git_repo_chip = None;
if !multi_repo_chip.is_empty() {
let chip_start = area.x + chev_str.chars().count() as u16 + 3; app.rects.git_repo_chip = Some(Rect {
x: chip_start,
y: git_header_y,
width: multi_repo_chip.chars().count() as u16,
height: 1,
});
}
let mut spans: Vec<Span<'static>> = Vec::with_capacity(3 + chip_count);
spans.push(Span::styled(
chev_str,
Style::default().fg(t.comment).bg(rail_bg),
));
spans.push(Span::styled(
label_str,
Style::default()
.fg(t.fg)
.bg(rail_bg)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
" ".repeat(pad_between),
Style::default().bg(rail_bg),
));
let cluster_start_x = area.x + (header_used + pad_between) as u16;
for (i, (glyph_nerd, glyph_ascii, action, fg)) in chips_full.iter().take(chip_count).enumerate()
{
let glyph = if nerd { *glyph_nerd } else { *glyph_ascii };
spans.push(Span::styled(
format!(" {glyph} "),
Style::default().fg(*fg).bg(rail_bg),
));
let chip_x = cluster_start_x + (i * chip_w) as u16;
app.rects.rail_git_header_buttons.push((
Rect {
x: chip_x,
y: git_header_y,
width: chip_w as u16,
height: 1,
},
*action,
));
}
let git_header_rect = Rect {
x: area.x,
y: git_header_y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(spans)), git_header_rect);
app.rects.git_section_toggle = Some(git_header_rect);
if !app.git_section_expanded {
hover_help_finish(frame, app, hover_help_area);
return;
}
let body_y = git_header_y + 1;
if body_y >= area.y + area.height {
hover_help_finish(frame, app, hover_help_area);
return;
}
draw_git_section(frame, app, area, body_y, nerd, git_overflow_rows);
hover_help_finish(frame, app, hover_help_area);
}
fn hover_help_finish(frame: &mut Frame, app: &mut App, area: Option<Rect>) {
if let Some(r) = area {
hover_help::draw(frame, app, r);
} else {
app.rects.hover_help_strip = None;
}
}
fn workspace_action_chip_specs(
app: &App,
) -> [(
&'static str,
&'static str,
&'static str,
ratatui::style::Color,
); 4] {
let t = theme::cur();
let (collapse_glyph, collapse_ascii) = if app.tree.is_fully_collapsed() {
("\u{F0AB4}", "↧") } else {
("\u{EAC5}", "↕") };
[
("\u{EA80}", "d+", "file.new_folder", t.blue),
("\u{EA7F}", "f+", "file.new", t.yellow),
("\u{EA9A}", "↓", "git.pull", t.green),
(
collapse_glyph,
collapse_ascii,
"tree.toggle_collapse_all",
t.teal,
),
]
}
fn workspace_header_chips(
app: &mut App,
frame: &mut Frame,
header_rect: Rect,
label_used: usize,
nerd: bool,
rail_bg: ratatui::style::Color,
) {
let chip_bg = rail_bg;
let chips = workspace_action_chip_specs(app);
let width = header_rect.width as usize;
let chip_w = 3usize;
let refresh_span_w = 3usize;
let right_margin = 1usize;
let refresh_gap = 1usize;
let min_separation = 1usize;
let chip_count = {
let mut n = chips.len();
while n > 0
&& label_used
+ min_separation
+ n * chip_w
+ refresh_gap
+ refresh_span_w
+ right_margin
> width
{
n -= 1;
}
n
};
let show_refresh = width >= label_used + min_separation + refresh_span_w + right_margin;
let chips_used = chip_count * chip_w;
let refresh_used = if show_refresh {
refresh_gap + refresh_span_w
} else {
0
};
let cluster_w = chips_used + refresh_used;
if cluster_w == 0 {
return;
}
let cluster_x = header_rect.x.saturating_add(
header_rect
.width
.saturating_sub((cluster_w + right_margin) as u16),
);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(chip_count + 2);
for (i, (glyph_nerd, glyph_ascii, cmd_id, fg)) in chips.iter().take(chip_count).enumerate() {
let glyph = if nerd { *glyph_nerd } else { *glyph_ascii };
spans.push(Span::styled(
format!(" {glyph} "),
Style::default().fg(*fg).bg(chip_bg),
));
app.rects.tree_icon_buttons.push((
Rect {
x: cluster_x + (i * chip_w) as u16,
y: header_rect.y,
width: chip_w as u16,
height: 1,
},
*cmd_id,
));
}
if show_refresh {
spans.push(Span::styled(
" ".repeat(refresh_gap),
Style::default().bg(rail_bg),
));
let refresh_glyph = if nerd {
crate::ui::refresh_glyph::NERD
} else {
crate::ui::refresh_glyph::ASCII
};
spans.push(Span::styled(
format!(" {refresh_glyph} "),
Style::default().fg(theme::cur().cyan).bg(chip_bg),
));
app.rects.tree_icon_buttons.push((
Rect {
x: cluster_x + (chips_used + refresh_gap) as u16,
y: header_rect.y,
width: refresh_span_w as u16,
height: 1,
},
"tree.refresh",
));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect {
x: cluster_x,
y: header_rect.y,
width: cluster_w as u16,
height: 1,
},
);
}
fn draw_add_repo_row(
frame: &mut Frame,
app: &mut App,
area: Rect,
y: u16,
nerd: bool,
rail_bg: ratatui::style::Color,
) {
let width = area.width as usize;
let glyph = if nerd { "\u{F0419}" } else { "+" };
let label = " Add workspace";
let chip_glyph_w = if nerd { 2usize } else { 1usize };
let label_w = label.chars().count();
let right_margin = 1usize;
let total = chip_glyph_w + label_w + right_margin;
if width < total + 1 {
return;
}
let pad = width.saturating_sub(total);
let row_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ".repeat(pad), Style::default().bg(rail_bg)),
Span::styled(
glyph.to_string(),
Style::default().fg(theme::cur().green).bg(rail_bg),
),
Span::styled(
label.to_string(),
Style::default().fg(theme::cur().comment).bg(rail_bg),
),
])),
row_rect,
);
app.rects.tree_icon_buttons.push((
Rect {
x: area.x + pad as u16,
y,
width: (chip_glyph_w + label_w) as u16,
height: 1,
},
"view.add_workspace",
));
}
fn visible_integration_indices(app: &App) -> Vec<usize> {
app.config
.ui
.integration_icons
.iter()
.enumerate()
.filter_map(|(i, ic)| {
if !ic.enabled {
return None;
}
match crate::integration_detect::integration_binary_for_command(&ic.command) {
None => Some(i), Some(bin) if crate::integration_detect::is_binary_installed(bin) => Some(i),
Some(_) => None,
}
})
.collect()
}
fn draw_integration_section(
frame: &mut Frame,
app: &mut App,
area: Rect,
start_y: u16,
height: u16,
nerd: bool,
rail_bg: ratatui::style::Color,
) {
if height == 0 {
return;
}
let t = theme::cur();
let width = area.width as usize;
let triangle = app.config.ui.expand_indicator == "triangle";
let chev = section_chev_with_pref(app.integration_section_expanded, nerd, triangle);
let chev_str = format!(" {chev} ");
let label = "INTEGRATIONS".to_string();
let used = chev_str.chars().count() + label.chars().count();
let pad = width.saturating_sub(used);
let header_rect = Rect {
x: area.x,
y: start_y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(chev_str, Style::default().fg(t.comment).bg(rail_bg)),
Span::styled(
label,
Style::default()
.fg(t.fg)
.bg(rail_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(pad), Style::default().bg(rail_bg)),
])),
header_rect,
);
app.rects.integration_section_toggle = Some(Rect {
x: area.x,
y: start_y,
width: area.width,
height: 1,
});
let max_y = start_y + height;
if !app.integration_section_expanded {
let visible = visible_integration_indices(app);
let n = visible.len();
if n == 0 {
if start_y + 1 < max_y {
let hint_rect = Rect {
x: area.x,
y: start_y + 1,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(rail_bg)),
Span::styled(
"no binaries on PATH",
Style::default()
.fg(t.comment)
.bg(rail_bg)
.add_modifier(Modifier::DIM),
),
]))
.style(Style::default().bg(rail_bg)),
hint_rect,
);
}
return;
}
const CHIP_W: usize = 4;
let per_row = (width / CHIP_W).max(1);
let icons: Vec<(usize, String, String, String)> = visible
.iter()
.map(|&i| {
let ic = &app.config.ui.integration_icons[i];
(i, ic.glyph.clone(), ic.fallback.clone(), ic.color.clone())
})
.collect();
for (row_y, chunk) in (start_y + 1..).zip(icons.chunks(per_row)) {
if row_y >= max_y {
break;
}
let mut spans: Vec<Span<'static>> = Vec::with_capacity(chunk.len() + 1);
for (slot_i, (i, glyph, fallback, color)) in chunk.iter().enumerate() {
let g = if nerd {
glyph.as_str()
} else {
fallback.as_str()
};
let fg = crate::ui::theme::color_from_slot(color.as_str(), &t);
let wide_glyph = matches!(glyph.as_str(), "\u{F1E00}" | "\u{F1E01}");
let chip_text = if wide_glyph {
format!(" {g} ")
} else {
format!(" {g} ")
};
spans.push(Span::styled(chip_text, Style::default().fg(fg).bg(rail_bg)));
let chip_x = area.x + (slot_i * CHIP_W) as u16;
app.rects.integration_icon_rects.push((
Rect {
x: chip_x,
y: row_y,
width: CHIP_W as u16,
height: 1,
},
*i,
));
}
let used = chunk.len() * CHIP_W;
spans.push(Span::styled(
" ".repeat(width.saturating_sub(used)),
Style::default().bg(rail_bg),
));
let row_rect = Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(spans)), row_rect);
}
return;
}
let visible = visible_integration_indices(app);
let icons: Vec<(usize, String, String, String, String)> = visible
.iter()
.map(|&i| {
let ic = &app.config.ui.integration_icons[i];
let label = ic.label.clone().unwrap_or_else(|| ic.id.clone());
(
i,
ic.glyph.clone(),
ic.fallback.clone(),
ic.color.clone(),
label,
)
})
.collect();
for (row_y, (i, glyph, fallback, color, label)) in (start_y + 1..).zip(icons.iter()) {
if row_y >= max_y {
break;
}
let g = if nerd {
glyph.as_str()
} else {
fallback.as_str()
};
let fg = crate::ui::theme::color_from_slot(color.as_str(), &t);
let icon_part = format!(" {g} ");
let label_cells = width.saturating_sub(icon_part.chars().count());
let label_display: String = label.chars().take(label_cells).collect();
let used = icon_part.chars().count() + label_display.chars().count();
let pad = width.saturating_sub(used);
let spans = vec![
Span::styled(icon_part, Style::default().fg(fg).bg(rail_bg)),
Span::styled(label_display, Style::default().fg(t.fg).bg(rail_bg)),
Span::styled(" ".repeat(pad), Style::default().bg(rail_bg)),
];
let row_rect = Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(spans)), row_rect);
app.rects.integration_icon_rects.push((row_rect, *i));
}
}
fn section_chev_with_pref(expanded: bool, nerd: bool, use_triangle: bool) -> &'static str {
if use_triangle {
return if expanded { "▾" } else { "▸" };
}
if expanded {
if nerd { CHEVRON_OPEN } else { "▼" }
} else if nerd {
CHEVRON_CLOSED
} else {
"▶"
}
}
fn draw_primary_workspace_section(
frame: &mut Frame,
app: &mut App,
area: Rect,
start_y: u16,
nerd: bool,
rail_bg: ratatui::style::Color,
) -> u16 {
let area_end = area.y + area.height;
if start_y >= area_end {
return start_y;
}
let ws_name = {
let full = app.workspace.display().to_string();
let home = std::env::var("HOME").unwrap_or_default();
let short = if !home.is_empty() && full.starts_with(&home) {
format!("~{}", &full[home.len()..])
} else {
full
};
format!("{short}/")
};
let triangle = app.config.ui.expand_indicator == "triangle";
let chev = section_chev_with_pref(app.tree_root_expanded, nerd, triangle);
let chev_str = format!(" {chev} ");
const CURRENT_DOT_W: usize = 2;
const CHIP_RESERVE: usize = 5 * 3 + 1;
let chrome_used = chev_str.chars().count() + CURRENT_DOT_W + CHIP_RESERVE;
let max_name_w = (area.width as usize).saturating_sub(chrome_used);
let ws_name = crate::ui::clip_to_cells(&ws_name, max_name_w.max(4));
let header_used = chev_str.chars().count() + CURRENT_DOT_W + ws_name.chars().count();
let header_rect = Rect {
x: area.x,
y: start_y,
width: area.width,
height: 1,
};
let name_x = area.x + chev_str.chars().count() as u16 + CURRENT_DOT_W as u16;
app.rects.workspace_name_rect = Some(Rect {
x: name_x,
y: start_y,
width: ws_name.chars().count() as u16,
height: 1,
});
let mut name_style = Style::default()
.fg(theme::cur().green)
.bg(rail_bg)
.add_modifier(Modifier::BOLD);
if app.tree.show_hidden {
name_style = name_style.add_modifier(Modifier::ITALIC);
}
let mut spans = vec![Span::styled(
chev_str,
Style::default().fg(theme::cur().comment).bg(rail_bg),
)];
if app.config.ui.show_workspace_dots {
spans.push(Span::styled(
"● ",
Style::default().fg(theme::cur().green).bg(rail_bg),
));
}
spans.push(Span::styled(ws_name.clone(), name_style));
frame.render_widget(Paragraph::new(Line::from(spans)), header_rect);
workspace_header_chips(app, frame, header_rect, header_used, nerd, rail_bg);
app.rects.tree_toggle = Some(header_rect);
let mut next_y = start_y + 1;
if app.tree_root_expanded && next_y < area_end {
let body_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area_end.saturating_sub(area.y),
};
next_y = draw_workspace_files(frame, app, body_area, next_y, nerd);
}
next_y
}
fn draw_workspace_files(
frame: &mut Frame,
app: &mut App,
area: Rect,
start_y: u16,
nerd: bool,
) -> u16 {
let rail_bg = theme::cur().bg_darker;
let width = area.width as usize;
let avail = (area.y + area.height).saturating_sub(start_y);
if avail == 0 {
return start_y;
}
let h = avail as usize;
if h == 0 {
return start_y;
}
let mut inner = Rect {
x: area.x,
y: start_y,
width: area.width,
height: h as u16,
};
let mut shift: u16 = 0;
let show_filter = app.tree.filter_mode || !app.tree.filter.is_empty();
if show_filter && inner.height >= 2 {
let t = theme::cur();
let cursor_glyph = if app.tree.filter_mode { "█" } else { "" };
let line = Line::from(vec![
Span::styled(" / ", Style::default().fg(t.yellow).bg(rail_bg)),
Span::styled(
app.tree.filter.clone(),
Style::default().fg(t.fg).bg(rail_bg),
),
Span::styled(
cursor_glyph.to_string(),
Style::default().fg(t.yellow).bg(rail_bg),
),
]);
let filter_rect = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(rail_bg)),
filter_rect,
);
inner = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height - 1,
};
shift += 1;
}
app.rects.tree = Some(inner);
let h = inner.height as usize;
if h == 0 {
return start_y + shift + inner.height;
}
if is_empty_workspace(app) {
draw_empty_workspace_state(frame, app, inner);
return start_y + shift + inner.height;
}
let rows = app.tree.visible_rows();
let cursor = app.tree.cursor();
if cursor < app.tree.scroll {
app.tree.scroll = cursor;
} else if cursor >= app.tree.scroll + h {
app.tree.scroll = cursor + 1 - h;
}
let max_scroll = rows.len().saturating_sub(h.min(rows.len()));
app.tree.scroll = app.tree.scroll.min(max_scroll);
app.rects.tree_scroll = app.tree.scroll;
let git_files = &app.git.snapshot().files;
let focused = app.focus == Focus::Tree && app.rail_section == RailSection::Workspace;
let multi_repo = app.repos.len() > 1;
let active_repo_path = app.repos.get(app.active_repo).map(|r| r.path.clone());
let needs_sb = rows.len() > h;
let sb_w: u16 = if needs_sb { 1 } else { 0 };
let mut lines: Vec<Line> = Vec::with_capacity(h);
const ROOT_INDENT: &str = " ";
let triangle = app.config.ui.expand_indicator == "triangle";
let connectors = crate::ui::tree_connectors::compute_prefixes(&rows, !nerd);
for (vi, row) in rows.iter().enumerate().skip(app.tree.scroll).take(h) {
let is_cursor = vi == cursor;
let is_repo_row = multi_repo
&& row.is_dir
&& row.depth == 0
&& app.repos.iter().any(|r| r.path == row.path);
let is_active_repo = is_repo_row && active_repo_path.as_ref() == Some(&row.path);
let (glyph, icon_color) = if is_repo_row {
if nerd {
if row.is_expanded {
icons::REPO_OPEN
} else {
icons::REPO_CLOSED
}
} else if row.is_expanded {
icons::REPO_OPEN_ASCII
} else {
icons::REPO_CLOSED_ASCII
}
} else {
icons::for_path(&row.path, row.is_dir, row.is_expanded, nerd)
};
let depth_indent = connectors
.get(vi)
.cloned()
.unwrap_or_else(|| " ".repeat(row.depth));
let indent_part = format!(" {}", depth_indent);
let (chev_part, icon_part) = if nerd && row.is_dir {
let c = section_chev_with_pref(row.is_expanded, nerd, triangle);
(format!("{c} "), format!("{glyph} "))
} else if nerd {
let slot = if row.depth >= 1 {
if crate::ui::tree_connectors::is_last_child(&rows, vi) {
"\u{F1F05} " } else {
"\u{F1F04} " }
} else {
" "
};
(slot.to_string(), format!("{glyph} "))
} else {
(String::new(), format!("{glyph} "))
};
let prefix_width = ROOT_INDENT.chars().count()
+ indent_part.chars().count()
+ chev_part.chars().count()
+ icon_part.chars().count();
let git_state = if row.is_dir {
None
} else {
git_files.get(&row.path).copied()
};
let is_dirty_in_editor = !row.is_dir
&& app.panes.iter().any(|p| match p {
crate::pane::Pane::Editor(b) => b.dirty && b.path.as_ref() == Some(&row.path),
_ => false,
});
let name_color = if is_repo_row {
theme::cur().yellow
} else if row.is_dir {
theme::cur().blue
} else {
match git_state {
Some(FileState::Modified) => theme::cur().yellow,
Some(FileState::Staged | FileState::Untracked) => theme::cur().green,
Some(FileState::Conflicted) => theme::cur().red,
None => theme::cur().fg,
}
};
let bg = row_bg(is_cursor, focused, rail_bg);
let mut name_style = Style::default().fg(name_color).bg(bg);
if row.is_dir || (is_cursor && focused) {
name_style = name_style.add_modifier(Modifier::BOLD);
}
if is_repo_row && !is_active_repo {
name_style = name_style.add_modifier(Modifier::DIM);
}
let is_hidden = row.name.starts_with('.');
if is_hidden {
name_style = name_style.add_modifier(Modifier::DIM);
}
let prefix_color = if is_repo_row {
theme::cur().yellow
} else if row.is_dir {
theme::cur().yellow
} else {
icon_color
};
let (badge, badge_color) = if is_dirty_in_editor {
("●", theme::cur().orange)
} else {
match git_state {
Some(FileState::Modified) => ("M", theme::cur().yellow),
Some(FileState::Staged) => ("A", theme::cur().green),
Some(FileState::Untracked) => ("?", theme::cur().green),
Some(FileState::Conflicted) => ("!", theme::cur().red),
None => ("", theme::cur().fg),
}
};
let badge_width = if badge.is_empty() { 0 } else { 2 };
let (repo_marker, repo_marker_color) = if is_repo_row && app.config.ui.show_workspace_dots {
if is_active_repo {
("● ", theme::cur().green)
} else {
("○ ", theme::cur().comment)
}
} else {
("", theme::cur().fg)
};
let repo_marker_width = repo_marker.chars().count();
let used = prefix_width + repo_marker_width + row.name.chars().count() + badge_width;
let pad = width.saturating_sub(sb_w as usize).saturating_sub(used);
let trace_fg = if is_cursor && focused {
theme::cur().bg3
} else {
theme::cur().bg2
};
let indent_style = Style::default().fg(trace_fg).bg(bg);
let chev_slot_fg = trace_fg;
let mut spans = vec![
Span::styled(" ", Style::default().bg(rail_bg)),
Span::styled(indent_part.clone(), indent_style),
Span::styled(chev_part, Style::default().fg(chev_slot_fg).bg(bg)),
Span::styled(icon_part, Style::default().fg(prefix_color).bg(bg)),
];
if !repo_marker.is_empty() {
spans.push(Span::styled(
repo_marker,
Style::default().fg(repo_marker_color).bg(bg),
));
}
spans.push(Span::styled(row.name.clone(), name_style));
spans.push(Span::styled(" ".repeat(pad), Style::default().bg(bg)));
if !badge.is_empty() {
spans.push(Span::styled(
format!("{badge} "),
Style::default().fg(badge_color).bg(bg),
));
}
lines.push(Line::from(spans));
}
let drew = lines.len() as u16;
let body = Rect {
width: inner.width.saturating_sub(sb_w),
..inner
};
frame.render_widget(Paragraph::new(lines), body);
if needs_sb {
let sb_area = Rect {
x: inner.x + body.width,
y: inner.y,
width: sb_w,
height: inner.height,
};
crate::ui::scrollbar::paint_simple_scrollbar(
frame,
sb_area,
&theme::cur(),
rows.len(),
h,
app.tree.scroll,
);
let hit_extra_left: u16 = 1;
let hit_x = sb_area.x.saturating_sub(hit_extra_left);
let hit_width = sb_area.x + sb_area.width - hit_x;
let hit_area = Rect {
x: hit_x,
y: sb_area.y,
width: hit_width,
height: sb_area.height,
};
app.rects.scrollbars.push(crate::app::ScrollbarHit {
area: hit_area,
pane_id: 0,
total: rows.len(),
viewport: h,
kind: crate::app::ScrollbarKind::Tree,
});
}
let _ = inner;
start_y + shift + drew
}
fn draw_extra_workspace_section(
frame: &mut Frame,
app: &mut App,
area: Rect,
start_y: u16,
ws_idx: usize,
nerd: bool,
) -> u16 {
let rail_bg = theme::cur().bg_darker;
let width = area.width as usize;
let area_end = area.y + area.height;
if start_y >= area_end {
return start_y;
}
let header_y = start_y;
let (name, expanded) = {
let ws = &app.extra_workspaces[ws_idx];
(ws.name.clone(), ws.expanded)
};
let triangle = app.config.ui.expand_indicator == "triangle";
let chev = section_chev_with_pref(expanded, nerd, triangle);
let chev_str = format!(" {chev} ");
let name = crate::ui::clip_to_cells(&name, (area.width as usize).saturating_sub(4).max(4));
let header_rect = Rect {
x: area.x,
y: header_y,
width: area.width,
height: 1,
};
let mut spans = vec![Span::styled(
chev_str,
Style::default().fg(theme::cur().comment).bg(rail_bg),
)];
if app.config.ui.show_workspace_dots {
spans.push(Span::styled(
"○ ",
Style::default().fg(theme::cur().comment).bg(rail_bg),
));
}
spans.push(Span::styled(
name.clone(),
Style::default()
.fg(theme::cur().fg)
.bg(rail_bg)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(Line::from(spans)), header_rect);
app.rects
.extra_workspace_toggles
.push((header_rect, ws_idx));
const CHEV_STR_W: u16 = 3;
let dot_rect = Rect {
x: area.x + CHEV_STR_W,
y: header_y,
width: 2,
height: 1,
};
app.rects
.extra_workspace_promote_dots
.push((dot_rect, ws_idx));
let _ = width;
if !expanded {
return header_y + 1;
}
let body_y = header_y + 1;
if body_y >= area_end {
return header_y + 1;
}
let avail = (area_end - body_y) as usize;
let reserved_for_add: usize = 2;
let h = avail.saturating_sub(reserved_for_add);
if h == 0 {
return body_y;
}
let rows_precount = app.extra_workspaces[ws_idx].tree.visible_rows().len();
let needs_sb = rows_precount > h;
let sb_w: u16 = if needs_sb { 1 } else { 0 };
let body_rect = Rect {
x: area.x,
y: body_y,
width: area.width.saturating_sub(sb_w),
height: h as u16,
};
app.rects.extra_workspace_bodies.push((
body_rect,
ws_idx,
app.extra_workspaces[ws_idx].tree.scroll,
));
let rows = app.extra_workspaces[ws_idx].tree.visible_rows();
let cursor = app.extra_workspaces[ws_idx].tree.cursor();
if cursor < app.extra_workspaces[ws_idx].tree.scroll {
app.extra_workspaces[ws_idx].tree.scroll = cursor;
} else if cursor >= app.extra_workspaces[ws_idx].tree.scroll + h {
app.extra_workspaces[ws_idx].tree.scroll = cursor + 1 - h;
}
let max_scroll = rows.len().saturating_sub(h.min(rows.len()));
let scroll = app.extra_workspaces[ws_idx].tree.scroll.min(max_scroll);
app.extra_workspaces[ws_idx].tree.scroll = scroll;
let multi_repo = app.repos.len() > 1;
let active_repo_path = app.repos.get(app.active_repo).map(|r| r.path.clone());
let focused =
matches!(app.focus, crate::focus::Focus::Tree) && app.focused_extra_ws == Some(ws_idx);
let cursor = app.extra_workspaces[ws_idx].tree.cursor();
let triangle = app.config.ui.expand_indicator == "triangle";
let mut lines: Vec<Line> = Vec::with_capacity(h);
const ROOT_INDENT: &str = " ";
let connectors = crate::ui::tree_connectors::compute_prefixes(&rows, !nerd);
for (vi, row) in rows.iter().enumerate().skip(scroll).take(h) {
let is_cursor = vi == cursor;
let row_bg_col = row_bg(is_cursor, focused, rail_bg);
let is_repo_row = multi_repo
&& row.is_dir
&& row.depth == 0
&& app.repos.iter().any(|r| r.path == row.path);
let is_active_repo = is_repo_row && active_repo_path.as_ref() == Some(&row.path);
let (glyph, icon_color) = if is_repo_row {
if nerd {
if row.is_expanded {
icons::REPO_OPEN
} else {
icons::REPO_CLOSED
}
} else if row.is_expanded {
icons::REPO_OPEN_ASCII
} else {
icons::REPO_CLOSED_ASCII
}
} else {
icons::for_path(&row.path, row.is_dir, row.is_expanded, nerd)
};
let depth_indent = connectors
.get(vi)
.cloned()
.unwrap_or_else(|| " ".repeat(row.depth));
let indent_part = format!(" {}", depth_indent);
let (chev_part, icon_part) = if nerd && row.is_dir {
let c = section_chev_with_pref(row.is_expanded, nerd, triangle);
(format!("{c} "), format!("{glyph} "))
} else if nerd {
let slot = if row.depth >= 1 {
if crate::ui::tree_connectors::is_last_child(&rows, vi) {
"\u{F1F05} " } else {
"\u{F1F04} " }
} else {
" "
};
(slot.to_string(), format!("{glyph} "))
} else {
(String::new(), format!("{glyph} "))
};
let prefix_width = ROOT_INDENT.chars().count()
+ indent_part.chars().count()
+ chev_part.chars().count()
+ icon_part.chars().count();
let name_color = if is_repo_row {
theme::cur().yellow
} else if row.is_dir {
theme::cur().blue
} else {
theme::cur().fg
};
let mut name_style = Style::default().fg(name_color).bg(row_bg_col);
if row.is_dir || (is_cursor && focused) {
name_style = name_style.add_modifier(Modifier::BOLD);
}
if is_repo_row && !is_active_repo {
name_style = name_style.add_modifier(Modifier::DIM);
}
if row.name.starts_with('.') {
name_style = name_style.add_modifier(Modifier::DIM);
}
let prefix_color = if is_repo_row {
theme::cur().yellow
} else if row.is_dir {
theme::cur().yellow
} else {
icon_color
};
let (repo_marker, repo_marker_color) = if is_repo_row && app.config.ui.show_workspace_dots {
if is_active_repo {
("● ", theme::cur().green)
} else {
("○ ", theme::cur().comment)
}
} else {
("", theme::cur().fg)
};
let used = prefix_width + repo_marker.chars().count() + row.name.chars().count();
let pad_n = (width.saturating_sub(sb_w as usize)).saturating_sub(used);
let trace_fg = if is_cursor && focused {
theme::cur().bg3
} else {
theme::cur().bg2
};
let indent_style = Style::default().fg(trace_fg).bg(row_bg_col);
let chev_slot_fg = trace_fg;
let mut spans = vec![
Span::styled(" ", Style::default().bg(rail_bg)),
Span::styled(indent_part.clone(), indent_style),
Span::styled(chev_part, Style::default().fg(chev_slot_fg).bg(row_bg_col)),
Span::styled(icon_part, Style::default().fg(prefix_color).bg(row_bg_col)),
];
if !repo_marker.is_empty() {
spans.push(Span::styled(
repo_marker,
Style::default().fg(repo_marker_color).bg(row_bg_col),
));
}
spans.push(Span::styled(row.name.clone(), name_style));
spans.push(Span::styled(
" ".repeat(pad_n),
Style::default().bg(row_bg_col),
));
lines.push(Line::from(spans));
}
let drew = lines.len() as u16;
frame.render_widget(Paragraph::new(lines), body_rect);
if needs_sb {
let sb_area = Rect {
x: body_rect.x + body_rect.width,
y: body_y,
width: sb_w,
height: h as u16,
};
crate::ui::scrollbar::paint_simple_scrollbar(
frame,
sb_area,
&theme::cur(),
rows_precount,
h,
scroll,
);
let hit_extra_left: u16 = 1;
let hit_x = sb_area.x.saturating_sub(hit_extra_left);
let hit_width = sb_area.x + sb_area.width - hit_x;
let hit_area = Rect {
x: hit_x,
y: sb_area.y,
width: hit_width,
height: sb_area.height,
};
app.rects.scrollbars.push(crate::app::ScrollbarHit {
area: hit_area,
pane_id: 0,
total: rows_precount,
viewport: h,
kind: crate::app::ScrollbarKind::ExtraTree(ws_idx),
});
}
body_y + drew
}
fn draw_git_section(
frame: &mut Frame,
app: &mut App,
area: Rect,
start_y: u16,
_nerd: bool,
overflow_rows: u16,
) {
let rail_bg = theme::cur().bg_darker;
let width = area.width as usize;
let avail = (area.y + area.height).saturating_sub(start_y) as usize;
if avail == 0 {
return;
}
let focused = app.focus == Focus::Tree && app.rail_section == RailSection::Git;
let cursor_row = app.git_rail.cursor;
let nb = app.git_rail.branches.len();
let mut lines: Vec<Line> = Vec::with_capacity(avail);
let mut row_y = start_y;
let mut row_count_drawn: usize = 0; const INDENT: &str = " ";
if !app.git_rail.branches.is_empty() {
push_sublabel(&mut lines, "branches", width, rail_bg);
row_y += 1;
if (row_y - start_y) as usize >= avail {
frame.render_widget(Paragraph::new(lines), git_body_rect(area, start_y));
return;
}
let total_branches = app.git_rail.branches.len();
let cap = if app.git_branches_expanded {
total_branches
} else {
total_branches.min(BRANCH_LIST_CAP)
};
let always_show_current = !app.git_branches_expanded && total_branches > BRANCH_LIST_CAP;
for (i, br) in app.git_rail.branches.iter().enumerate() {
if (row_y - start_y) as usize >= avail {
break;
}
let in_cap = i < cap;
let force_show = always_show_current && br.is_current && !in_cap;
if !in_cap && !force_show {
continue;
}
let is_cur_row = row_count_drawn == cursor_row;
let bg = row_bg(is_cur_row, focused, rail_bg);
let marker = if br.is_current { "●" } else { "○" };
let marker_color = if br.is_current {
theme::cur().green
} else {
theme::cur().fg
};
let name = &br.name;
let prefix = format!("{INDENT}{marker} ");
let used = prefix.chars().count() + name.chars().count();
let pad = width.saturating_sub(used);
let mut name_style = Style::default().fg(theme::cur().fg).bg(bg);
if br.is_current {
name_style = name_style.add_modifier(Modifier::BOLD);
}
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(marker_color).bg(bg)),
Span::styled(name.clone(), name_style),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::Branch(i),
));
row_y += 1;
row_count_drawn += 1;
}
if total_branches > BRANCH_LIST_CAP && (row_y - start_y) as usize <= avail {
let toggle_text = if app.git_branches_expanded {
" show less".to_string()
} else {
format!(" + {} more", total_branches - cap)
};
let pad = width.saturating_sub(toggle_text.chars().count());
lines.push(Line::from(vec![
Span::styled(
toggle_text,
Style::default()
.fg(theme::cur().comment)
.bg(rail_bg)
.add_modifier(Modifier::ITALIC),
),
Span::styled(" ".repeat(pad), Style::default().bg(rail_bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::ToggleBranches,
));
row_y += 1;
}
}
if !app.git_rail.worktrees.is_empty() && ((row_y - start_y) as usize) < avail {
push_sublabel(&mut lines, "worktrees", width, rail_bg);
row_y += 1;
for (i, wt) in app.git_rail.worktrees.iter().enumerate() {
if (row_y - start_y) as usize >= avail {
break;
}
let row_idx = nb + i;
let is_cur_row = row_idx == cursor_row;
let bg = row_bg(is_cur_row, focused, rail_bg);
let marker = if wt.is_current { "⤿" } else { "·" };
let marker_color = if wt.is_current {
theme::cur().yellow
} else {
theme::cur().fg
};
let label = if wt.label.is_empty() {
"(detached)".to_string()
} else {
wt.label.clone()
};
let dir = wt
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
let shown = if label == dir || label.starts_with('(') {
label.clone()
} else {
format!("{label} ({dir})")
};
let prefix = format!("{INDENT}{marker} ");
let used = prefix.chars().count() + shown.chars().count();
let pad = width.saturating_sub(used);
let mut name_style = Style::default().fg(theme::cur().fg).bg(bg);
if wt.is_current {
name_style = name_style.add_modifier(Modifier::BOLD);
}
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(marker_color).bg(bg)),
Span::styled(shown, name_style),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::Worktree(i),
));
row_y += 1;
}
}
if !app.git_rail.pulls.is_empty() && ((row_y - start_y) as usize) < avail {
push_sublabel(&mut lines, "open prs", width, rail_bg);
row_y += 1;
let nb_and_nw = nb + app.git_rail.worktrees.len();
for (i, pr) in app.git_rail.pulls.iter().enumerate() {
if (row_y - start_y) as usize >= avail {
break;
}
let row_idx = nb_and_nw + i;
let is_cur_row = row_idx == cursor_row;
let bg = row_bg(is_cur_row, focused, rail_bg);
let host_color = match pr.host_tag {
"BB" => theme::cur().blue,
"GH" => theme::cur().fg,
"GL" => theme::cur().orange,
"AZ" => theme::cur().cyan,
_ => theme::cur().fg,
};
let marker = if pr.is_current_branch { "●" } else { "○" };
let avail_for_title =
width.saturating_sub(2 + 1 + 1 + pr.number_label.chars().count() + 1);
let title_disp = truncate_chars(&pr.title, avail_for_title);
let prefix = format!(" {marker} ");
let head = format!("{} ", pr.number_label);
let used = prefix.chars().count() + head.chars().count() + title_disp.chars().count();
let pad = width.saturating_sub(used);
let mut title_style = Style::default().fg(theme::cur().fg).bg(bg);
if pr.is_current_branch {
title_style = title_style.add_modifier(Modifier::BOLD);
}
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(host_color).bg(bg)),
Span::styled(head, Style::default().fg(host_color).bg(bg)),
Span::styled(title_disp, title_style),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::Pull(i),
));
row_y += 1;
}
}
let nb_and_nw = nb + app.git_rail.worktrees.len();
let npulls = app.git_rail.pulls.len();
if !app.git_rail.stashes.is_empty() && ((row_y - start_y) as usize) < avail {
push_sublabel(&mut lines, "stashes", width, rail_bg);
row_y += 1;
for (i, st) in app.git_rail.stashes.iter().enumerate() {
if (row_y - start_y) as usize >= avail {
break;
}
let row_idx = nb_and_nw + npulls + i;
let is_cur_row = row_idx == cursor_row;
let bg = row_bg(is_cur_row, focused, rail_bg);
let label = format!("{} {}", st.id, st.summary);
let prefix = format!("{INDENT}\u{1FAA3} "); let ascii_prefix = format!("{INDENT}s ");
let prefix_str = if _nerd {
prefix.as_str()
} else {
ascii_prefix.as_str()
};
let max_label = width.saturating_sub(prefix_str.chars().count());
let label_disp = truncate_chars(&label, max_label);
let used = prefix_str.chars().count() + label_disp.chars().count();
let pad = width.saturating_sub(used);
lines.push(Line::from(vec![
Span::styled(
prefix_str.to_string(),
Style::default().fg(theme::cur().purple).bg(bg),
),
Span::styled(label_disp, Style::default().fg(theme::cur().fg).bg(bg)),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::Stash(i),
));
row_y += 1;
}
}
let nstashes = app.git_rail.stashes.len();
if !app.git_rail.tags.is_empty() && ((row_y - start_y) as usize) < avail {
push_sublabel(&mut lines, "tags", width, rail_bg);
row_y += 1;
for (i, name) in app.git_rail.tags.iter().enumerate() {
if (row_y - start_y) as usize >= avail {
break;
}
let row_idx = nb_and_nw + npulls + nstashes + i;
let is_cur_row = row_idx == cursor_row;
let bg = row_bg(is_cur_row, focused, rail_bg);
let prefix = format!("{INDENT}\u{F02B2} "); let ascii_prefix = format!("{INDENT}# ");
let prefix_str = if _nerd {
prefix.as_str()
} else {
ascii_prefix.as_str()
};
let max_label = width.saturating_sub(prefix_str.chars().count());
let name_disp = truncate_chars(name, max_label);
let used = prefix_str.chars().count() + name_disp.chars().count();
let pad = width.saturating_sub(used);
lines.push(Line::from(vec![
Span::styled(
prefix_str.to_string(),
Style::default().fg(theme::cur().cyan).bg(bg),
),
Span::styled(name_disp, Style::default().fg(theme::cur().fg).bg(bg)),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
app.rects.git_rail_rows.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
GitRailHit::Tag(i),
));
row_y += 1;
}
}
if app.git_rail.is_empty() {
push_sublabel(&mut lines, "no git repo here", width, rail_bg);
}
if overflow_rows > 0 && !lines.is_empty() {
let hidden = overflow_rows as usize + 1;
let s = format!(" … {hidden} more");
let pad = width.saturating_sub(s.chars().count());
let last_idx = lines.len() - 1;
lines[last_idx] = Line::from(vec![
Span::styled(
s,
Style::default()
.fg(theme::cur().comment)
.bg(rail_bg)
.add_modifier(Modifier::ITALIC),
),
Span::styled(" ".repeat(pad), Style::default().bg(rail_bg)),
]);
}
let body = git_body_rect(area, start_y);
frame.render_widget(Paragraph::new(lines), body);
}
fn truncate_chars(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let count = s.chars().count();
if count <= max {
return s.to_string();
}
if max <= 1 {
return s.chars().take(max).collect();
}
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
fn git_body_rect(area: Rect, start_y: u16) -> Rect {
Rect {
x: area.x,
y: start_y,
width: area.width,
height: area.height.saturating_sub(start_y - area.y),
}
}
fn push_sublabel(lines: &mut Vec<Line>, text: &str, width: usize, bg: ratatui::style::Color) {
let s = format!(" {text}");
let pad = width.saturating_sub(s.chars().count());
lines.push(Line::from(vec![
Span::styled(s, Style::default().fg(theme::cur().comment).bg(bg)),
Span::styled(" ".repeat(pad), Style::default().bg(bg)),
]));
}
fn row_bg(is_cursor: bool, focused: bool, rail_bg: ratatui::style::Color) -> ratatui::style::Color {
if is_cursor {
if focused {
theme::cur().bg2
} else {
theme::cur().bg
}
} else {
rail_bg
}
}
fn is_empty_workspace(app: &App) -> bool {
let Some(home) = std::env::var_os("HOME") else {
return false;
};
let home = std::path::PathBuf::from(home);
let home_c = std::fs::canonicalize(&home).unwrap_or(home);
app.workspace == home_c
}
fn draw_empty_workspace_state(frame: &mut Frame, app: &mut App, inner: Rect) {
let t = theme::cur();
let rail_bg = t.bg_darker;
let mut lines: Vec<(String, Option<&'static str>, ratatui::style::Color)> = vec![
("No workspace open".to_string(), None, t.comment),
(String::new(), None, t.comment),
("▸ Open file…".to_string(), Some("view.discovery"), t.fg),
(
"▸ Open folder…".to_string(),
Some("view.add_workspace"),
t.fg,
),
(
"▸ Switch workspace…".to_string(),
Some("view.switch_workspace"),
t.fg,
),
(
"▸ Manage workspaces…".to_string(),
Some("view.manage_workspaces"),
t.fg,
),
];
if let Some(dw) = &app.config.default_workspace
&& dw != &app.workspace
{
let label = dw
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dw.to_string_lossy().into_owned());
lines.push((
format!("▸ Open default workspace ({label})"),
Some("view.open_default_workspace"),
t.fg,
));
}
for (i, (text, cmd, color)) in lines.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let row = Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
};
let style = Style::default().fg(*color).bg(rail_bg);
let para_text = format!(" {text}");
frame.render_widget(
Paragraph::new(Line::from(Span::styled(para_text, style)))
.style(Style::default().bg(rail_bg)),
row,
);
if let Some(cmd_id) = cmd {
app.rects.tree_icon_buttons.push((row, *cmd_id));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draw_paints_workspace_files() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let d = tempfile::tempdir().unwrap();
let ws = d.path().to_path_buf();
std::fs::write(ws.join("alpha.txt"), "a\n").unwrap();
std::fs::write(ws.join("beta.txt"), "b\n").unwrap();
let mut app = App::new(ws.clone(), crate::config::Config::default()).unwrap();
let mut term = Terminal::new(TestBackend::new(32, 24)).unwrap();
term.draw(|f| draw(f, &mut app, f.area())).unwrap();
let buf = term.backend().buffer();
let mut screen = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
screen.push_str(buf[(x, y)].symbol());
}
screen.push('\n');
}
assert!(
screen.contains("alpha.txt"),
"tree missing alpha.txt:\n{screen}"
);
assert!(
screen.contains("beta.txt"),
"tree missing beta.txt:\n{screen}"
);
}
#[test]
fn audit_tree_icon_button_rects_cover_visible_chip() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let d = tempfile::tempdir().unwrap();
let ws = d.path().to_path_buf();
std::fs::write(ws.join("alpha.txt"), "a\n").unwrap();
let mut app = App::new(ws.clone(), crate::config::Config::default()).unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| crate::ui::draw(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let is_visible = |x: u16, y: u16| -> bool {
x < buf.area.width && y < buf.area.height && !buf[(x, y)].symbol().trim().is_empty()
};
assert!(
!app.rects.tree_icon_buttons.is_empty(),
"audit precondition: tree_icon_buttons was empty — \
the 80×24 TestBackend isn't wide enough to render any \
chip (workspace name `{ws}` may be too long for the \
header cluster). Test would pass vacuously.",
ws = ws.file_name().unwrap_or_default().to_string_lossy(),
);
let rect_contains = |x: u16, y: u16| -> bool {
app.rects
.tree_icon_buttons
.iter()
.any(|(r, _)| x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)
};
for (rect, label) in &app.rects.tree_icon_buttons {
for y in rect.y..rect.y.saturating_add(rect.height) {
if rect.x > 0 && is_visible(rect.x - 1, y) && !rect_contains(rect.x - 1, y) {
panic!(
"tree_icon_button rect `{label}` at ({x},{y},{w}x{h}): visible glyph at ({lx},{y}) is OUTSIDE the rect (off-by-one to the left)",
x = rect.x,
w = rect.width,
h = rect.height,
lx = rect.x - 1,
);
}
let right_x = rect.x + rect.width;
let right_glyph = if right_x < buf.area.width {
buf[(right_x, y)].symbol()
} else {
""
};
let is_divider = right_glyph == "│" || right_glyph == "┃";
if is_visible(right_x, y) && !is_divider && !rect_contains(right_x, y) {
panic!(
"tree_icon_button rect `{label}` at ({x},{y},{w}x{h}): visible glyph at ({rx},{y}) is OUTSIDE the rect (off-by-one to the right)",
x = rect.x,
w = rect.width,
h = rect.height,
rx = right_x,
);
}
}
}
}
}