fix: retain repository status between picker openings

This commit is contained in:
Valdemar Lorenzen
2026-10-14 14:36:42 +02:00
parent 77a9111f45
commit 1cb9a0ddfc
7 changed files with 166 additions and 73 deletions
+19 -12
View File
@@ -1830,6 +1830,7 @@ pub struct Editor {
startup_diagnostic: Option<StartupDiagnostic>,
project_root: PathBuf,
file_picker: Option<FilePicker>,
picker_repository_changes: Vec<crate::repository::WorkingFileChange>,
file_explorer_side: DockSide,
file_picker_view: FileView,
file_explorer_view: FileView,
@@ -1958,6 +1959,7 @@ impl Editor {
startup_diagnostic: None,
project_root: std::env::current_dir()?,
file_picker: None,
picker_repository_changes: Vec::new(),
file_explorer_side: DockSide::Right,
file_picker_view: FileView::Tree,
file_explorer_view: FileView::Tree,
@@ -4790,21 +4792,24 @@ impl Editor {
pub(crate) fn set_picker_repository_changes(
&mut self,
tab: usize,
root: &Path,
changes: Vec<crate::repository::WorkingFileChange>,
) -> bool {
let picker = if tab == self.tab_id {
self.file_picker.as_mut().filter(|_| self.diff.is_none())
} else {
self.tabs
.get_mut(&tab)
.and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none()))
};
let Some(picker) = picker.filter(|picker| picker.root == root) else {
if root != self.project_root {
return false;
};
picker.set_repository_changes(changes)
}
let mut changed = false;
for picker in self.file_picker.iter_mut().chain(
self.tabs
.values_mut()
.filter_map(|tab| tab.file_picker.as_mut()),
) {
if picker.root == root {
changed |= picker.set_repository_changes(changes.clone());
}
}
self.picker_repository_changes = changes;
changed
}
pub fn file_explorer_side(&self) -> DockSide {
@@ -4861,6 +4866,7 @@ impl Editor {
#[cfg(test)]
pub(crate) fn set_project_root(&mut self, root: PathBuf) {
self.project_root = root;
self.picker_repository_changes.clear();
}
/// Pending decimal prefix for the next Normal/Visual motion or editing action.
pub fn count(&self) -> Option<usize> {
@@ -5950,7 +5956,8 @@ impl Editor {
picker.clear_error();
} else {
match FilePicker::scan(self.project_root.clone(), presentation, view) {
Ok(picker) => {
Ok(mut picker) => {
picker.set_repository_changes(self.picker_repository_changes.clone());
self.file_picker = Some(picker);
}
Err(error) => {
-9
View File
@@ -101,7 +101,6 @@ pub struct FilePicker {
tree_searching: bool,
warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
has_repository_changes: bool,
has_virtual_entries: bool,
}
@@ -307,7 +306,6 @@ impl FilePicker {
tree_searching: false,
warning,
repository_changes: Vec::new(),
has_repository_changes: false,
};
for root in roots {
let tree = picker.compact(root, None);
@@ -780,10 +778,6 @@ impl FilePicker {
}
}
pub(crate) fn has_repository_changes(&self) -> bool {
self.has_repository_changes
}
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
if !self.has_virtual_entries && changes == self.repository_changes {
return false;
@@ -919,7 +913,6 @@ impl FilePicker {
replacement.error = self.error.clone();
self.replace_catalog(replacement);
}
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
self.repository_changes = changes;
topology_changed || metadata_changed
}
@@ -1125,7 +1118,6 @@ mod tests {
assert_eq!(picker.query, "src");
assert!(!picker.focused);
assert_eq!(picker.presentation, FilePresentation::Docked);
assert!(picker.has_repository_changes());
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "src", "modified.rs", "deleted.rs"] {
@@ -1166,7 +1158,6 @@ mod tests {
picker.reveal_path(Path::new("src/clean.rs"));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert!(!picker.has_repository_changes());
assert_eq!(picker.entries().len(), 4);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(
+87 -28
View File
@@ -376,7 +376,7 @@ pub struct EditorSession {
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
picker_source: Option<(usize, PathBuf)>,
picker_source: Option<PathBuf>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -1275,15 +1275,8 @@ impl EditorSession {
fn poll_gutter_at(&mut self, now: Instant) -> bool {
let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None;
let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) {
(Some(picker), Some((tab, root))) => {
*tab != self.editor.tab_id() || *root != picker.root
}
(None, None) => false,
_ => true,
};
if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none())
{
let picker_changed = self.picker_source.as_deref() != Some(self.editor.project_root());
if self.editor.should_quit() {
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
@@ -1320,10 +1313,7 @@ impl EditorSession {
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
if sources_changed || picker_changed || refresh_due {
if picker_changed {
self.picker_source = self
.editor
.normal_file_picker()
.map(|picker| (self.editor.tab_id(), picker.root.clone()));
self.picker_source = Some(self.editor.project_root().to_owned());
}
if sources_changed {
self.gutter_sources = self
@@ -1357,11 +1347,6 @@ impl EditorSession {
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
self.gutter_worker = None;
self.gutter_pending = false;
return false;
}
if self.gutter_worker.is_none() {
match crate::gutter::Worker::start() {
Ok(worker) => self.gutter_worker = Some(worker),
@@ -1383,7 +1368,7 @@ impl EditorSession {
)
})
.collect();
let picker = self.picker_source.as_ref().map(|(_, root)| {
let picker = self.picker_source.as_ref().map(|root| {
let buffers = self
.gutter_sources
.iter()
@@ -1451,20 +1436,15 @@ impl EditorSession {
}
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
let Some((tab, root)) = &self.picker_source else {
let Some(root) = &self.picker_source else {
return false;
};
if id != self.gutter_request_id
|| *tab != self.editor.tab_id()
|| *root != reply.root
|| self
.editor
.normal_file_picker()
.is_none_or(|picker| picker.root != *root)
|| *root != self.editor.project_root()
{
return false;
}
let tab = *tab;
let mut changed = false;
let changes = match reply.result {
Ok(changes) => changes,
@@ -1477,7 +1457,7 @@ impl EditorSession {
changed
| self
.editor
.set_picker_repository_changes(tab, &reply.root, changes)
.set_picker_repository_changes(&reply.root, changes)
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
@@ -1816,6 +1796,16 @@ mod tests {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let status = |session: &EditorSession| {
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.unwrap()
.status
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
@@ -1845,16 +1835,56 @@ mod tests {
thread::sleep(Duration::from_millis(5));
}
};
let settle = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(now);
if !session.gutter_pending {
break;
}
assert!(
Instant::now() < deadline,
"background file status did not settle"
);
thread::sleep(Duration::from_millis(5));
}
};
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"first open must reuse status loaded while the picker was closed",
);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"reopening must retain completed status",
);
let request = session.gutter_request_id;
session.poll_gutter_at(now);
assert_eq!(
session.gutter_request_id, request,
"opening must not restart status work"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
None,
"closed-picker edits must refresh the cache"
);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
@@ -1863,11 +1893,40 @@ mod tests {
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
settle(&mut session);
open(&mut session);
assert_eq!(status(&session), Some(FileStatus::Modified));
ready(&mut session, now, Some(FileStatus::Modified));
git(&["add", "buffer.txt"]);
git(&["commit", "-qm", "new baseline"]);
ready(&mut session, now + Duration::from_secs(1), None);
assert!(!session.apply_picker_result(
session.gutter_request_id.wrapping_sub(1),
crate::gutter::PickerChanges {
root: fixture.0.clone(),
result: Ok(Vec::new()),
},
));
assert!(!session.apply_picker_result(
session.gutter_request_id,
crate::gutter::PickerChanges {
root: fixture.0.join("elsewhere"),
result: Ok(Vec::new()),
},
));
session.handle(Input::Ctrl('w'));
session.execute("tab.new");
open(&mut session);
assert_eq!(status(&session), None);
assert!(
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.any(|row| row.path == Path::new("added.txt")
&& row.status == Some(FileStatus::Added))
);
}
#[test]
+57 -10
View File
@@ -4649,7 +4649,6 @@ 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() {
@@ -4722,7 +4721,7 @@ fn draw_file_list(
Style::default().fg(color),
));
spans.push(Span::raw(" "));
} else if normal_markers {
} else if changed_files.is_none() {
spans.push(Span::raw(" "));
}
if status == Some(FileStatus::Renamed)
@@ -7559,7 +7558,6 @@ mod tests {
}
}
picker.refresh();
assert!(picker.has_repository_changes());
assert!(
picker
.entries()
@@ -7581,13 +7579,57 @@ mod tests {
})
.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_file_names_stay_put_when_status_arrives_or_clears() {
let directory = Directory::new();
directory.file("file.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
let mut columns = Vec::new();
for status in [None, Some(FileStatus::Modified), None] {
picker.set_repository_changes(
status
.into_iter()
.map(|status| crate::repository::WorkingFileChange {
path: "file.txt".into(),
status,
old_path: None,
})
.collect(),
);
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
columns.push(text.lines().find_map(|row| row.find("file.txt")).unwrap());
}
assert_eq!(columns[0], columns[1], "status arrival moved the filename");
assert_eq!(columns[1], columns[2], "clearing status moved the filename");
}
}
#[test]
fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() {
let directory = Directory::new();
@@ -13167,7 +13209,7 @@ end
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None))
.unwrap();
assert!(panels.manual_files.is_none());
assert!(screen_text(&terminal).contains("> file-10"));
assert!(screen_text(&terminal).contains("file-10"));
}
#[test]
@@ -13306,7 +13348,11 @@ end
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&format!("> {label}")), "{text}");
assert!(
text.lines()
.any(|row| row.contains(label) && row.contains('>')),
"{text}"
);
let buffer = terminal.backend().buffer();
let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
@@ -13451,8 +13497,10 @@ end
let selected_y = (area.y + 2..area.bottom() - 1)
.find(|&y| buffer[(area.x + 1, y)].symbol() == ">")
.unwrap();
assert_eq!(buffer[(area.x + 3, selected_y)].symbol(), "界");
assert_eq!(buffer[(area.x + 5, selected_y)].symbol(), "e\u{301}");
let selected_x = (area.x + 2..area.right())
.find(|&x| buffer[(x, selected_y)].symbol() == "界")
.unwrap();
assert_eq!(buffer[(selected_x + 2, selected_y)].symbol(), "e\u{301}");
assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue);
assert!(
buffer[(area.x + 1, selected_y)]
@@ -13729,7 +13777,6 @@ end
" nested/",
" inner.txt",
" sibling.txt",
"> single/",
" only.txt",
" root.txt",
] {
@@ -13833,7 +13880,7 @@ end
}
if width == 100 {
let text = screen_text(&terminal);
assert!(text.contains("> file.txt"), "{text}");
assert!(text.contains("file.txt"), "{text}");
assert!(!text.contains("dir-00"), "{text}");
}
for query in ["missing", long_query.as_str()] {