use ratatui::{
Frame,
layout::{Position, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use std::path::Path;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, FileTreeItem, FocusedPanel};
use crate::ui::diff_view::apply_horizontal_scroll;
use crate::ui::styles;
const EXPANDED_GLYPH: &str = "\u{25bc}"; const COLLAPSED_GLYPH: &str = "\u{25b6}"; const REVIEWED_BOX: &str = "\u{25a3}"; const UNREVIEWED_BOX: &str = "\u{25a2}";
pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focused_panel == FocusedPanel::FileList;
let mut title = format!(
" Files \u{00b7} {}/{} ",
app.reviewed_count(),
app.file_count()
);
if app.file_filter_active() {
title.push_str(&format!(
"\u{00b7} {} of {} ",
app.file_count(),
app.unfiltered_file_count()
));
}
let mut block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, focused));
if let Some(bottom) = filter_footer(app) {
block = block.title_bottom(bottom);
}
let inner = block.inner(area);
app.file_list_inner_area = Some(inner);
let visible_items = app.build_visible_items();
let max_content_width = visible_items
.iter()
.map(|item| match item {
FileTreeItem::Directory { path, depth, .. } => {
let dir_name = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
depth * 2 + 2 + dir_name.width() + 1
}
FileTreeItem::File { file_idx, depth } => {
let file = &app.diff_files[*file_idx];
let filename = file
.display_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
depth * 2 + 4 + filename.width()
}
})
.max()
.unwrap_or(0);
app.file_list_state.viewport_width = inner.width as usize;
app.file_list_state.viewport_height = inner.height as usize;
app.file_list_state.max_content_width = max_content_width;
let max_scroll_x = max_content_width.saturating_sub(inner.width as usize);
if app.file_list_state.scroll_x > max_scroll_x {
app.file_list_state.scroll_x = max_scroll_x;
}
let scroll_x = app.file_list_state.scroll_x;
if app.focused_panel == FocusedPanel::Diff {
let current_file_idx = app.diff_state.current_file_idx;
for (tree_idx, item) in visible_items.iter().enumerate() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx == current_file_idx
{
if app.file_list_state.selected() != tree_idx {
let current_offset = app.file_list_state.list_state.offset();
app.file_list_state.select(tree_idx);
*app.file_list_state.list_state.offset_mut() = current_offset;
}
break;
}
}
}
let items: Vec<ListItem> = visible_items
.iter()
.map(|item| {
let line = match item {
FileTreeItem::Directory {
path,
depth,
expanded,
} => {
let indent = " ".repeat(*depth);
let icon = if *expanded {
EXPANDED_GLYPH
} else {
COLLAPSED_GLYPH
};
let dir_name = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
Line::from(vec![
Span::raw(indent),
Span::styled(format!("{icon} "), styles::dir_icon_style(&app.theme)),
Span::raw(format!("{dir_name}/")),
])
}
FileTreeItem::File { file_idx, depth } => {
let file = &app.diff_files[*file_idx];
let path = file.display_path();
let is_reviewed = app.session.is_file_reviewed(path);
let checkbox = if is_reviewed {
REVIEWED_BOX
} else {
UNREVIEWED_BOX
};
let checkbox_style = if is_reviewed {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
};
if file.is_commit_message {
Line::from(vec![
Span::styled(format!("{checkbox} "), checkbox_style),
Span::raw(format!(" {}", path.display())),
])
} else {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let indent = " ".repeat(*depth);
let mut spans = vec![
Span::raw(indent),
Span::styled(format!("{checkbox} "), checkbox_style),
];
if !app.is_pristine_mode {
let status = file.status.as_char();
spans.push(Span::styled(
format!("{status} "),
styles::file_status_style(&app.theme, status),
));
}
spans.push(Span::raw(filename.to_string()));
Line::from(spans)
}
}
};
ListItem::new(apply_horizontal_scroll(line, scroll_x))
})
.collect();
let list = List::new(items)
.style(styles::panel_style(&app.theme))
.highlight_style(styles::selected_style(&app.theme))
.block(block);
frame.render_stateful_widget(list, area, &mut app.file_list_state.list_state);
if let Some(draft) = app.file_tree_draft() {
let prefix = PROMPT_PAD + 2 + draft.buffer.width() as u16;
frame.set_cursor_position(Position {
x: (area.x + prefix).min(area.x + area.width.saturating_sub(1)),
y: area.y + area.height.saturating_sub(1),
});
}
}
const PROMPT_PAD: u16 = 2;
fn filter_footer(app: &App) -> Option<Line<'static>> {
let theme = &app.theme;
if let Some(draft) = app.file_tree_draft() {
return Some(Line::from(vec![
Span::styled(
format!(" {} ", draft.prompt.sigil()),
styles::mode_style(theme),
),
Span::styled(
format!("{} ", draft.buffer),
Style::default().fg(theme.fg_primary),
),
]));
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut push = |label: char, value: String| {
if !spans.is_empty() {
spans.push(Span::styled(
" \u{00b7} ",
Style::default().fg(theme.fg_dim),
));
}
spans.push(Span::styled(
format!("{label}:"),
Style::default().fg(theme.fg_dim),
));
spans.push(Span::styled(value, Style::default().fg(theme.fg_secondary)));
};
if let Some(include) = app.file_filter.include.as_ref() {
push('i', include.source.clone());
}
if let Some(exclude) = app.file_filter.exclude.as_ref() {
push('e', exclude.source.clone());
}
if spans.is_empty() {
return None;
}
spans.insert(0, Span::raw(" "));
spans.push(Span::raw(" "));
Some(Line::from(spans))
}
#[cfg(test)]
mod tests {
use crate::app::{App, DiffSource, FileTreePrompt, FocusedPanel, InputMode};
use crate::model::{DiffFile, DiffLine, FileStatus, ReviewSession, SessionDiffSource};
use crate::vcs::traits::{VcsBackend, VcsInfo, VcsType};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use std::path::PathBuf;
struct StubVcs(VcsInfo);
impl VcsBackend for StubVcs {
fn info(&self) -> &VcsInfo {
&self.0
}
fn get_working_tree_diff(
&self,
_hl: &crate::syntax::SyntaxHighlighter,
) -> crate::error::Result<Vec<DiffFile>> {
Ok(Vec::new())
}
fn fetch_context_lines(
&self,
_path: &std::path::Path,
_status: FileStatus,
_ref_commit: Option<&str>,
_start: u32,
_end: u32,
) -> crate::error::Result<Vec<DiffLine>> {
Ok(Vec::new())
}
fn file_line_count(
&self,
_path: &std::path::Path,
_status: FileStatus,
_ref_commit: Option<&str>,
) -> crate::error::Result<u32> {
Ok(0)
}
}
fn file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash: 0,
}
}
fn app_with(paths: &[&str]) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp"),
head_commit: "head".into(),
branch_name: Some("main".into()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
let mut app = App::build(
Box::new(StubVcs(vcs_info.clone())),
vcs_info,
crate::theme::Theme::dark(),
None,
false,
paths.iter().map(|p| file(p)).collect(),
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
None,
)
.expect("build app");
app.show_file_list = true;
app.focused_panel = FocusedPanel::FileList;
app
}
fn draw(app: &mut App) -> Buffer {
let backend = TestBackend::new(120, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| crate::ui::render(frame, app))
.expect("draw frame");
terminal.backend().buffer().clone()
}
fn buffer_text(buffer: &Buffer) -> String {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn should_render_the_prompt_sigil_and_buffer_while_a_prompt_is_open() {
let mut app = app_with(&["src/main.rs", "README.md"]);
app.begin_file_tree_prompt(FileTreePrompt::Include);
for ch in r"\.rs$".chars() {
app.file_tree_prompt_insert_char(ch);
}
let text = buffer_text(&draw(&mut app));
assert!(
text.contains(r"i \.rs$"),
"expected the include prompt in the tree border, got:\n{text}"
);
}
#[test]
fn should_render_applied_filters_in_the_border_after_the_prompt_closes() {
let mut app = app_with(&["src/main.rs", "tests/smoke.rs", "README.md"]);
app.begin_file_tree_prompt(FileTreePrompt::Include);
for ch in r"\.rs$".chars() {
app.file_tree_prompt_insert_char(ch);
}
app.commit_file_tree_prompt();
app.begin_file_tree_prompt(FileTreePrompt::Exclude);
for ch in "^tests/".chars() {
app.file_tree_prompt_insert_char(ch);
}
app.commit_file_tree_prompt();
let text = buffer_text(&draw(&mut app));
assert!(
text.contains(r"i:\.rs$") && text.contains("e:^tests/"),
"expected both applied patterns in the tree border, got:\n{text}"
);
assert!(
text.contains("1 of 3"),
"expected a filtered/total count in the tree title, got:\n{text}"
);
}
#[test]
fn should_leave_the_border_clean_when_no_filter_is_set() {
let mut app = app_with(&["src/main.rs"]);
let text = buffer_text(&draw(&mut app));
assert!(
!text.contains("i:") && !text.contains("e:"),
"unfiltered tree should not advertise filters, got:\n{text}"
);
}
}