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
+181 -9
View File
@@ -170,6 +170,7 @@ struct GutterSource {
revision: u64,
save_revision: u64,
path: PathBuf,
dirty: bool,
contents: Arc<str>,
}
@@ -198,6 +199,7 @@ pub struct EditorSession {
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
picker_source: Option<(usize, PathBuf)>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -312,6 +314,7 @@ impl EditorSession {
gutter_request_id: 0,
gutter_pending: false,
last_gutter_refresh: None,
picker_source: None,
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
@@ -356,6 +359,14 @@ impl EditorSession {
}
pub fn handle(&mut self, input: Input) {
if input == Input::Ctrl('r')
&& self
.editor
.normal_file_picker()
.is_some_and(|picker| picker.focused)
{
self.last_gutter_refresh = None;
}
self.cancel_pending_popup_request();
let invocation = self.editor.handle(input);
self.synchronize_documents();
@@ -1056,21 +1067,36 @@ impl EditorSession {
}
fn poll_gutter_at(&mut self, now: Instant) -> bool {
if self.editor.should_quit()
|| self.editor.gutter_style() == crate::gutter::GutterStyle::None
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())
{
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
self.picker_source = None;
return false;
}
let sources_changed = !self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
document
.path
.map(|path| (document.id, document.revision, document.save_revision, path))
document.path.map(|path| {
(
document.id,
document.revision,
document.save_revision,
path,
document.dirty,
)
})
})
.eq(self.gutter_sources.iter().map(|source| {
(
@@ -1078,17 +1104,25 @@ impl EditorSession {
source.revision,
source.save_revision,
source.path.as_path(),
source.dirty,
)
}));
let refresh_due = !self.gutter_pending
&& self
.last_gutter_refresh
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
if sources_changed || refresh_due {
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()));
}
if sources_changed {
self.gutter_sources = self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
let path = document.path?;
let contents = self
@@ -1108,6 +1142,7 @@ impl EditorSession {
revision: document.revision,
save_revision: document.save_revision,
path: path.to_path_buf(),
dirty: document.dirty,
contents,
})
})
@@ -1115,7 +1150,7 @@ 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() {
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
self.gutter_worker = None;
self.gutter_pending = false;
return false;
@@ -1132,6 +1167,7 @@ impl EditorSession {
let files = self
.gutter_sources
.iter()
.filter(|_| line_gutter)
.map(|source| {
(
source.buffer,
@@ -1140,11 +1176,20 @@ impl EditorSession {
)
})
.collect();
let picker = self.picker_source.as_ref().map(|(_, root)| {
let buffers = self
.gutter_sources
.iter()
.filter(|source| source.dirty)
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
.collect();
(root.clone(), buffers)
});
match self
.gutter_worker
.as_mut()
.expect("gutter worker started")
.request(self.gutter_request_id, files)
.request(self.gutter_request_id, files, picker)
{
Ok(()) => self.gutter_pending = true,
Err(error) => {
@@ -1156,12 +1201,15 @@ impl EditorSession {
}
}
let mut changed = false;
while let Some((id, result)) = self
while let Some((id, result, picker)) = self
.gutter_worker
.as_mut()
.and_then(crate::gutter::Worker::poll)
{
changed |= self.apply_gutter_result(id, result);
if let Some(picker) = picker {
changed |= self.apply_picker_result(id, picker);
}
}
changed
}
@@ -1195,6 +1243,36 @@ impl EditorSession {
changed
}
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
let Some((tab, 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)
{
return false;
}
let tab = *tab;
let mut changed = false;
let changes = match reply.result {
Ok(changes) => changes,
Err(error) => {
self.editor.set_message(format!("File status: {error}"));
changed = true;
Vec::new()
}
};
changed
| self
.editor
.set_picker_repository_changes(tab, &reply.root, changes)
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
self.last_gutter_refresh = None;
self.poll_gutter_at(Instant::now())
@@ -1399,6 +1477,100 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
use crate::diff::FileStatus;
let fixture = Fixture::new();
fixture.file("buffer.txt", "base\n");
fixture.file("clean.txt", "clean\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q", "--template="]);
git(&["add", "buffer.txt", "clean.txt"]);
git(&["commit", "-qm", "baseline"]);
fixture.file("buffer.txt", "disk\n");
fixture.file("added.txt", "new\n");
let mut session = fixture.open(
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
);
session.editor.set_project_root(fixture.0.clone());
let now = session.last_file_refresh;
let open = |session: &mut EditorSession| {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(clock);
let picker = session.editor.normal_file_picker().unwrap();
let status = picker
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.expect("normal picker retained the buffer file")
.status;
if !session.gutter_pending && status == expected {
assert!(
picker.entries().any(|row| {
row.path == Path::new("clean.txt") && row.status.is_none()
})
);
assert!(picker.entries().any(|row| {
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
}));
break;
}
assert!(
Instant::now() < deadline,
"normal picker status did not settle: {}",
session.editor.message()
);
thread::sleep(Duration::from_millis(5));
}
};
open(&mut session);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
open(&mut session);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
open(&mut session);
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);
}
#[test]
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
use crate::gutter::Change;