feat: add Git and Jujutsu gutter indicators
This commit is contained in:
+220
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user