feat: show repository changes in normal file pickers

This commit is contained in:
2026-10-13 21:10:20 +02:00
parent e90d1c3945
commit 9526876b04
12 changed files with 2169 additions and 33 deletions
+263 -8
View File
@@ -4269,6 +4269,7 @@ fn draw_file_list(
);
}
let entries = picker.entries();
let normal_markers = changed_files.is_none() && picker.has_repository_changes();
if entries.len() == 0 {
frame.render_widget(
Paragraph::new(if picker.query.is_empty() {
@@ -4304,6 +4305,10 @@ fn draw_file_list(
let changed = entry
.catalog_index
.and_then(|index| changed_files.and_then(|(files, _)| files.get(index)));
let status = changed.map(|file| file.status).or(entry.status);
let old_path = changed
.and_then(|file| file.old_path.as_deref())
.or(entry.old_path);
let prefix = format!(
"{} {:indent$}",
if selected { ">" } else { " " },
@@ -4314,9 +4319,9 @@ fn draw_file_list(
0
},
);
let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 });
let mut spans = Vec::with_capacity(if status.is_some() { 7 } else { 3 });
spans.push(Span::raw(prefix));
if let Some(file) = changed {
if changed.is_some() {
let reviewed = entry.catalog_index.is_some_and(|index| {
changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index))
});
@@ -4324,25 +4329,32 @@ fn draw_file_list(
if reviewed { "✓ " } else { " " },
Style::default().fg(Color::Green),
));
let color = match file.status {
}
if let Some(status) = status {
let color = match status {
FileStatus::Added => Color::Green,
FileStatus::Removed => Color::Red,
FileStatus::Modified => Color::Yellow,
FileStatus::Renamed => Color::Cyan,
};
spans.push(Span::styled(
diff_status(file.status),
diff_status(status),
Style::default().fg(color),
));
spans.push(Span::raw(" "));
} else if normal_markers {
spans.push(Span::raw(" "));
}
if let Some(file) = changed
&& file.status == FileStatus::Renamed
&& let Some(old_path) = &file.old_path
if status == Some(FileStatus::Renamed)
&& let Some(old_path) = old_path
{
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
spans.push(Span::raw(" -> "));
spans.push(Span::raw(display_text(&file.path.to_string_lossy())));
spans.push(Span::raw(display_text(
&changed
.map_or(entry.path, |file| file.path.as_path())
.to_string_lossy(),
)));
} else {
spans.push(Span::raw(entry.label));
if entry.is_dir && picker.view == FileView::Tree {
@@ -5956,6 +5968,249 @@ mod tests {
}
}
#[test]
fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() {
use crate::repository::WorkingFileChange;
let directory = Directory::new();
let changes: Vec<_> = [
("z.txt", FileStatus::Added),
("chain/a/m.txt", FileStatus::Modified),
("gone/deep/d.txt", FileStatus::Removed),
("r.txt", FileStatus::Renamed),
]
.into_iter()
.map(|(path, status)| {
if status != FileStatus::Removed {
directory.file(path, "");
}
WorkingFileChange {
path: path.into(),
status,
old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()),
}
})
.collect();
directory.file("clean.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
picker.set_repository_changes(changes.clone());
for change in &changes {
picker.reveal_path(&change.path);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
picker.presentation = presentation;
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
for focused in [true, false] {
picker.focused = focused;
for selected in changes
.iter()
.map(|change| change.path.as_path())
.chain(std::iter::once(std::path::Path::new("clean.txt")))
{
picker.reveal_path(selected);
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("clean.txt"));
assert!(!text.contains('✓'));
assert!(!text.contains("old\nname.txt"));
let buffer = terminal.backend().buffer();
for (change, color) in changes.iter().zip([
Color::Green,
Color::Yellow,
Color::Red,
Color::Cyan,
]) {
let path = if view == FileView::Tree {
std::path::Path::new(change.path.file_name().unwrap())
} else {
change.path.as_path()
};
let expected = if change.status == FileStatus::Renamed {
"R old�name.txt -> r.txt".to_owned()
} else {
format!("{} {}", diff_status(change.status), path.to_string_lossy())
};
let (x, y) = (0..24)
.find_map(|y| {
let row = (0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>();
row.find(&expected).map(|offset| {
(UnicodeWidthStr::width(&row[..offset]) as u16, y)
})
})
.unwrap_or_else(|| panic!("missing {expected}"));
assert_eq!(buffer[(x, y)].fg, color);
assert_eq!(
buffer[(x, y)].bg,
if change.path == selected {
if focused {
Color::Blue
} else {
Color::DarkGray
}
} else {
Color::Reset
}
);
}
let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap();
assert!(clean_row.contains(
if selected == std::path::Path::new("clean.txt") {
"> clean.txt"
} else {
" clean.txt"
}
));
}
for query in ["m.txt", "clean"] {
picker.query = query.into();
picker.refilter();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
if query == "m.txt" {
assert!(text.contains("M "));
assert!(!text.contains("clean.txt"));
} else {
assert!(text.contains("> clean.txt"));
assert!(!text.contains("M "));
}
assert!(!text.contains('✓'));
picker.clear_filter();
}
}
}
}
picker.refresh();
assert!(picker.has_repository_changes());
assert!(
picker
.entries()
.any(|row| row.status == Some(FileStatus::Renamed))
);
picker.set_repository_changes(Vec::new());
picker.set_view(FileView::List);
picker.reveal_path(std::path::Path::new("clean.txt"));
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("> clean.txt"));
assert!(text.contains("r.txt"));
assert!(!text.contains("old�name.txt"));
assert!(!text.contains("d.txt"));
assert!(!text.contains('✓'));
}
#[test]
fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() {
let directory = Directory::new();
directory.file("界.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
picker.set_repository_changes(vec![crate::repository::WorkingFileChange {
path: "界.txt".into(),
status: FileStatus::Modified,
old_path: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap();
for focused in [true, false] {
picker.focused = focused;
for height in 0..=3 {
for width in 0..=8 {
terminal
.draw(|frame| {
frame.render_widget(
Paragraph::new(
"XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX",
),
frame.area(),
);
draw_file_list(
frame,
&picker,
Rect::new(0, 0, width, height),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..4 {
for x in 0..12 {
if x >= width || y >= height {
assert_eq!(buffer[(x, y)].symbol(), "X");
}
}
}
if height < 2 {
continue;
}
for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() {
assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string());
}
if width >= 3 {
assert_eq!(buffer[(2, 1)].fg, Color::Yellow);
assert_eq!(
buffer[(2, 1)].bg,
if focused {
Color::Blue
} else {
Color::DarkGray
}
);
}
if width >= 6 {
assert_eq!(buffer[(4, 1)].symbol(), "界");
}
}
}
}
}
#[test]
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
let mut diff = diff_comparison(vec![deletion_file()]);