feat: add Git and Jujutsu gutter indicators

This commit is contained in:
2026-10-13 17:44:29 +02:00
parent e84d70463f
commit 55cdee1a13
14 changed files with 1392 additions and 32 deletions
+220
View File
@@ -165,6 +165,14 @@ struct AsyncRequests {
latest: Option<(u64, InvocationSource)>,
}
struct GutterSource {
buffer: usize,
revision: u64,
save_revision: u64,
path: PathBuf,
contents: Arc<str>,
}
/// Owns command eligibility and execution hosts together. Frontends can inspect
/// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession {
@@ -185,6 +193,11 @@ pub struct EditorSession {
blame_source: Option<BlameSource>,
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
blame_request_id: u64,
gutter_worker: Option<crate::gutter::Worker>,
gutter_sources: Vec<GutterSource>,
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -240,6 +253,7 @@ impl EditorSession {
));
}
editor.set_git_blame(config.git_blame);
editor.set_gutter_style(config.git_gutter);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
@@ -293,6 +307,11 @@ impl EditorSession {
blame_source: None,
blame_lines: None,
blame_request_id: 0,
gutter_worker: None,
gutter_sources: Vec::new(),
gutter_request_id: 0,
gutter_pending: false,
last_gutter_refresh: None,
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
@@ -301,6 +320,7 @@ impl EditorSession {
};
session.synchronize_documents();
session.poll_battery();
session.poll_gutter_at(Instant::now());
Ok(session)
}
@@ -1035,6 +1055,151 @@ impl EditorSession {
Ok(changed)
}
fn poll_gutter_at(&mut self, now: Instant) -> bool {
if self.editor.should_quit()
|| self.editor.gutter_style() == crate::gutter::GutterStyle::None
{
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
return false;
}
let sources_changed = !self
.editor
.documents()
.filter_map(|document| {
document
.path
.map(|path| (document.id, document.revision, document.save_revision, path))
})
.eq(self.gutter_sources.iter().map(|source| {
(
source.buffer,
source.revision,
source.save_revision,
source.path.as_path(),
)
}));
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 {
self.gutter_sources = self
.editor
.documents()
.filter_map(|document| {
let path = document.path?;
let contents = self
.gutter_sources
.binary_search_by_key(&document.id, |source| source.buffer)
.ok()
.map(|index| &self.gutter_sources[index])
.filter(|source| {
source.revision == document.revision && source.path == path
})
.map_or_else(
|| Arc::from(document.lines.join("\n")),
|source| Arc::clone(&source.contents),
);
Some(GutterSource {
buffer: document.id,
revision: document.revision,
save_revision: document.save_revision,
path: path.to_path_buf(),
contents,
})
})
.collect();
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
if self.gutter_sources.is_empty() {
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),
Err(error) => {
self.editor.set_message(format!("Gutter: {error}"));
return true;
}
}
}
let files = self
.gutter_sources
.iter()
.map(|source| {
(
source.buffer,
source.path.clone(),
Arc::clone(&source.contents),
)
})
.collect();
match self
.gutter_worker
.as_mut()
.expect("gutter worker started")
.request(self.gutter_request_id, files)
{
Ok(()) => self.gutter_pending = true,
Err(error) => {
self.gutter_worker = None;
self.gutter_pending = false;
self.editor.set_message(format!("Gutter: {error}"));
return true;
}
}
}
let mut changed = false;
while let Some((id, result)) = self
.gutter_worker
.as_mut()
.and_then(crate::gutter::Worker::poll)
{
changed |= self.apply_gutter_result(id, result);
}
changed
}
fn apply_gutter_result(&mut self, id: u64, result: crate::gutter::ResultBatch) -> bool {
if id != self.gutter_request_id {
return false;
}
self.gutter_pending = false;
let mut changed = false;
for (buffer, result) in result {
let Some(source) = self
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
else {
continue;
};
let changes = match result {
Ok(changes) => changes,
Err(error) => {
self.editor.set_message(format!("Gutter: {error}"));
changed = true;
Vec::new()
}
};
changed |=
self.editor
.set_gutter_changes(buffer, source.revision, &source.path, changes);
}
changed
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
self.last_gutter_refresh = None;
self.poll_gutter_at(Instant::now())
}
fn poll_blame(&mut self) -> bool {
let mut changed = false;
if !self
@@ -1168,6 +1333,7 @@ impl EditorSession {
fn poll_at(&mut self, now: Instant) -> bool {
let mut changed = self.refresh_files_at(now);
changed |= self.poll_gutter_at(now);
changed |= self.synchronize_documents();
changed |= self.poll_battery();
self.refresh_checkout_diffs_at(now);
@@ -1233,6 +1399,60 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
use crate::gutter::Change;
let fixture = Fixture::new();
let first_path = fixture.file("first.txt", "first\nsecond");
let second_path = fixture.file("second.txt", "other");
let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap();
let now = session.last_gutter_refresh.unwrap();
let first_buffer = session.editor.buffer_id();
let first_request = session.gutter_request_id;
assert!(session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))],
));
session.editor.handle(Input::Down);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session
.editor
.execute(&format!("edit {}", second_path.display()));
session.poll_gutter_at(now);
let second_buffer = session.editor.buffer_id();
let second_request = session.gutter_request_id;
assert_ne!(second_request, first_request);
assert!(!session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))],
));
assert!(session.apply_gutter_result(
second_request,
vec![
(first_buffer, Ok(vec![None, Some(Change::Modified)])),
(second_buffer, Ok(vec![Some(Change::Added)])),
],
));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
session.editor.execute(&format!("buffer {first_buffer}"));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('i'));
session.editor.handle(Input::Char('x'));
assert_eq!(session.editor.gutter_change(1), None);
assert!(!session.apply_gutter_result(
second_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
));
session.poll_gutter_at(now);
assert!(session.apply_gutter_result(
session.gutter_request_id,
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
));
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
session.editor.execute(&format!("buffer {second_buffer}"));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
}
#[test]
fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() {
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};