Separate accepted edit predictions in edit history (#49056)

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-13 00:15:59 +00:00
1 parent 500f87da88
commit aa4e1b47dc
5 files changed
+469 -99

No files matched your search

@@ -445,9 +445,7 @@ mod tests {
cx.run_until_parked();
// Verify the external edit was recorded in events
let events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx));
assert!(
matches!(
events
+188 -69
View File
@@ -230,6 +230,71 @@ pub enum UserActionType {
pub struct StoredEvent {
pub event: Arc<zeta_prompt::Event>,
pub old_snapshot: TextBufferSnapshot,
pub edit_range: Range<Anchor>,
}
impl StoredEvent {
fn can_merge(
&self,
next_old_event: &&&StoredEvent,
new_snapshot: &TextBufferSnapshot,
last_edit_range: &Range<Anchor>,
) -> bool {
// Events must be for the same buffer
if self.old_snapshot.remote_id() != next_old_event.old_snapshot.remote_id() {
return false;
}
let a_is_predicted = matches!(
self.event.as_ref(),
zeta_prompt::Event::BufferChange {
predicted: true,
..
}
);
let b_is_predicted = matches!(
next_old_event.event.as_ref(),
zeta_prompt::Event::BufferChange {
predicted: true,
..
}
);
// If events come from the same source (both predicted or both manual) then
// we would have coalesced them already.
if a_is_predicted == b_is_predicted {
return false;
}
let left_range = self.edit_range.to_point(new_snapshot);
let right_range = next_old_event.edit_range.to_point(new_snapshot);
let latest_range = last_edit_range.to_point(&new_snapshot);
// Events near to the latest edit are not merged if their sources differ.
if lines_between_ranges(&left_range, &latest_range)
.min(lines_between_ranges(&right_range, &latest_range))
<= CHANGE_GROUPING_LINE_SPAN
{
return false;
}
// Events that are distant from each other are not merged.
if lines_between_ranges(&left_range, &right_range) > CHANGE_GROUPING_LINE_SPAN {
return false;
}
true
}
}
fn lines_between_ranges(left: &Range<Point>, right: &Range<Point>) -> u32 {
if left.start > right.end {
return left.start.row - right.end.row;
}
if right.start > left.end {
return right.start.row - left.end.row;
}
0
}
struct ProjectState {
@@ -259,18 +324,6 @@ impl ProjectState {
}
pub fn events(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
.chain(
self.last_event
.as_ref()
.and_then(|event| event.finalize(&self.license_detection_watchers, cx)),
)
.collect()
}
pub fn events_split_by_pause(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
@@ -429,6 +482,7 @@ struct LastEvent {
old_file: Option<Arc<dyn File>>,
new_file: Option<Arc<dyn File>>,
edit_range: Option<Range<Anchor>>,
predicted: bool,
snapshot_after_last_editing_pause: Option<TextBufferSnapshot>,
last_edit_time: Option<Instant>,
}
@@ -453,7 +507,8 @@ impl LastEvent {
})
});
let diff = compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?;
let (diff, edit_range) =
compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?;
if path == old_path && diff.is_empty() {
None
@@ -464,9 +519,10 @@ impl LastEvent {
path,
diff,
in_open_source_repo,
// TODO: Actually detect if this edit was predicted or not
predicted: false,
predicted: self.predicted,
}),
edit_range: self.new_snapshot.anchor_before(edit_range.start)
..self.new_snapshot.anchor_before(edit_range.end),
old_snapshot: self.old_snapshot.clone(),
})
}
@@ -483,6 +539,7 @@ impl LastEvent {
old_file: self.old_file.clone(),
new_file: self.new_file.clone(),
edit_range: None,
predicted: self.predicted,
snapshot_after_last_editing_pause: None,
last_edit_time: self.last_edit_time,
};
@@ -493,6 +550,7 @@ impl LastEvent {
old_file: self.old_file.clone(),
new_file: self.new_file.clone(),
edit_range: None,
predicted: self.predicted,
snapshot_after_last_editing_pause: None,
last_edit_time: self.last_edit_time,
};
@@ -504,7 +562,7 @@ impl LastEvent {
pub(crate) fn compute_diff_between_snapshots(
old_snapshot: &TextBufferSnapshot,
new_snapshot: &TextBufferSnapshot,
) -> Option<String> {
) -> Option<(String, Range<Point>)> {
let edits: Vec<Edit<usize>> = new_snapshot
.edits_since::<usize>(&old_snapshot.version)
.collect();
@@ -544,7 +602,7 @@ pub(crate) fn compute_diff_between_snapshots(
new_context_start_row,
);
Some(diff)
Some((diff, new_start_point..new_end_point))
}
fn buffer_path_with_id_fallback(
@@ -715,17 +773,6 @@ impl EditPredictionStore {
.unwrap_or_default()
}
pub fn edit_history_for_project_with_pause_split_last_event(
&self,
project: &Entity<Project>,
cx: &App,
) -> Vec<StoredEvent> {
self.projects
.get(&project.entity_id())
.map(|project_state| project_state.events_split_by_pause(cx))
.unwrap_or_default()
}
pub fn context_for_project<'a>(
&'a self,
project: &Entity<Project>,
@@ -1019,7 +1066,7 @@ impl EditPredictionStore {
if let language::BufferEvent::Edited = event
&& let Some(project) = project.upgrade()
{
this.report_changes_for_buffer(&buffer, &project, cx);
this.report_changes_for_buffer(&buffer, &project, false, cx);
}
}
}),
@@ -1040,6 +1087,7 @@ impl EditPredictionStore {
&mut self,
buffer: &Entity<Buffer>,
project: &Entity<Project>,
is_predicted: bool,
cx: &mut Context<Self>,
) {
let project_state = self.get_or_init_project(project, cx);
@@ -1073,30 +1121,32 @@ impl EditPredictionStore {
last_offset = Some(edit.new.end);
}
if num_edits > 0 {
let action_type = match (total_deleted, total_inserted, num_edits) {
(0, ins, n) if ins == n => UserActionType::InsertChar,
(0, _, _) => UserActionType::InsertSelection,
(del, 0, n) if del == n => UserActionType::DeleteChar,
(_, 0, _) => UserActionType::DeleteSelection,
(_, ins, n) if ins == n => UserActionType::InsertChar,
(_, _, _) => UserActionType::InsertSelection,
};
let Some(edit_range) = edit_range else {
return;
};
if let Some(offset) = last_offset {
let point = new_snapshot.offset_to_point(offset);
let timestamp_epoch_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
project_state.record_user_action(UserActionRecord {
action_type,
buffer_id: buffer.entity_id(),
line_number: point.row,
offset,
timestamp_epoch_ms,
});
}
let action_type = match (total_deleted, total_inserted, num_edits) {
(0, ins, n) if ins == n => UserActionType::InsertChar,
(0, _, _) => UserActionType::InsertSelection,
(del, 0, n) if del == n => UserActionType::DeleteChar,
(_, 0, _) => UserActionType::DeleteSelection,
(_, ins, n) if ins == n => UserActionType::InsertChar,
(_, _, _) => UserActionType::InsertSelection,
};
if let Some(offset) = last_offset {
let point = new_snapshot.offset_to_point(offset);
let timestamp_epoch_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
project_state.record_user_action(UserActionRecord {
action_type,
buffer_id: buffer.entity_id(),
line_number: point.row,
offset,
timestamp_epoch_ms,
});
}
let events = &mut project_state.events;
@@ -1107,20 +1157,18 @@ impl EditPredictionStore {
== last_event.new_snapshot.remote_id()
&& old_snapshot.version == last_event.new_snapshot.version;
let prediction_source_changed = is_predicted != last_event.predicted;
let should_coalesce = is_next_snapshot_of_same_buffer
&& edit_range
&& !prediction_source_changed
&& last_event
.edit_range
.as_ref()
.zip(last_event.edit_range.as_ref())
.is_some_and(|(a, b)| {
let a = a.to_point(&new_snapshot);
let b = b.to_point(&new_snapshot);
if a.start > b.end {
a.start.row.abs_diff(b.end.row) <= CHANGE_GROUPING_LINE_SPAN
} else if b.start > a.end {
b.start.row.abs_diff(a.end.row) <= CHANGE_GROUPING_LINE_SPAN
} else {
true
}
.is_some_and(|last_edit_range| {
lines_between_ranges(
&edit_range.to_point(&new_snapshot),
&last_edit_range.to_point(&new_snapshot),
) <= CHANGE_GROUPING_LINE_SPAN
});
if should_coalesce {
@@ -1133,7 +1181,7 @@ impl EditPredictionStore {
Some(last_event.new_snapshot.clone());
}
last_event.edit_range = edit_range;
last_event.edit_range = Some(edit_range);
last_event.new_snapshot = new_snapshot;
last_event.last_edit_time = Some(now);
return;
@@ -1149,12 +1197,15 @@ impl EditPredictionStore {
}
}
merge_trailing_events_if_needed(events, &old_snapshot, &new_snapshot, &edit_range);
project_state.last_event = Some(LastEvent {
old_file,
new_file,
old_snapshot,
new_snapshot,
edit_range,
edit_range: Some(edit_range),
predicted: is_predicted,
snapshot_after_last_editing_pause: None,
last_edit_time: Some(now),
});
@@ -1201,11 +1252,18 @@ impl EditPredictionStore {
}
fn accept_current_prediction(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
let Some(current_prediction) = self
.projects
.get_mut(&project.entity_id())
.and_then(|project_state| project_state.current_prediction.take())
else {
return;
};
let Some(current_prediction) = project_state.current_prediction.take() else {
self.report_changes_for_buffer(&current_prediction.prediction.buffer, project, true, cx);
// can't hold &mut project_state ref across report_changes_for_buffer_call
let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
return;
};
@@ -1727,7 +1785,7 @@ impl EditPredictionStore {
self.get_or_init_project(&project, cx);
let project_state = self.projects.get(&project.entity_id()).unwrap();
let stored_events = project_state.events_split_by_pause(cx);
let stored_events = project_state.events(cx);
let has_events = !stored_events.is_empty();
let events: Vec<Arc<zeta_prompt::Event>> =
stored_events.into_iter().map(|e| e.event).collect();
@@ -2211,6 +2269,67 @@ impl EditPredictionStore {
}
}
fn merge_trailing_events_if_needed(
events: &mut VecDeque<StoredEvent>,
end_snapshot: &TextBufferSnapshot,
latest_snapshot: &TextBufferSnapshot,
latest_edit_range: &Range<Anchor>,
) {
let mut next_old_event = None;
let mut mergeable_count = 0;
for old_event in events.iter().rev() {
if let Some(next_old_event) = &next_old_event
&& !old_event.can_merge(&next_old_event, latest_snapshot, latest_edit_range)
{
break;
}
mergeable_count += 1;
next_old_event = Some(old_event);
}
if mergeable_count <= 1 {
return;
}
let mut events_to_merge = events.range(events.len() - mergeable_count..).peekable();
let oldest_event = events_to_merge.peek().unwrap();
let oldest_snapshot = oldest_event.old_snapshot.clone();
if let Some((diff, edited_range)) =
compute_diff_between_snapshots(&oldest_snapshot, end_snapshot)
{
let merged_event = match oldest_event.event.as_ref() {
zeta_prompt::Event::BufferChange {
old_path,
path,
in_open_source_repo,
..
} => StoredEvent {
event: Arc::new(zeta_prompt::Event::BufferChange {
old_path: old_path.clone(),
path: path.clone(),
diff,
in_open_source_repo: *in_open_source_repo,
predicted: events_to_merge.all(|e| {
matches!(
e.event.as_ref(),
zeta_prompt::Event::BufferChange {
predicted: true,
..
}
)
}),
}),
old_snapshot: oldest_snapshot.clone(),
edit_range: end_snapshot.anchor_before(edited_range.start)
..end_snapshot.anchor_before(edited_range.end),
},
};
events.truncate(events.len() - mergeable_count);
events.push_back(merged_event);
}
}
pub(crate) fn filter_redundant_excerpts(
mut related_files: Vec<RelatedFile>,
cursor_path: &Path,
@@ -355,26 +355,9 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
buffer.edit(vec![(19..19, "!")], None, cx);
});
// Without time-based splitting, there is one event.
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(events.len(), 1);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -1,3 +1,3 @@
Hello!
-
+How are you?!
Bye
"}
);
// With time-based splitting, there are two distinct events.
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx)
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(events.len(), 2);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
@@ -403,7 +386,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
}
#[gpui::test]
async fn test_event_grouping_line_span_coalescing(cx: &mut TestAppContext) {
async fn test_predicted_edits_are_separated_in_edit_history(cx: &mut TestAppContext) {
let (ep_store, _requests) = init_test_with_fake_client(cx);
let fs = FakeFs::new(cx.executor());
@@ -592,6 +575,278 @@ fn render_events(events: &[StoredEvent]) -> String {
.join("\n---\n")
}
fn render_events_with_predicted(events: &[StoredEvent]) -> Vec<String> {
events
.iter()
.map(|e| {
let zeta_prompt::Event::BufferChange {
diff, predicted, ..
} = e.event.as_ref();
let prefix = if *predicted { "predicted" } else { "manual" };
format!("{}\n{}", prefix, diff)
})
.collect()
}
#[gpui::test]
async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) {
let (ep_store, _requests) = init_test_with_fake_client(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"foo.rs": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\n"
}),
)
.await;
let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap();
project.open_buffer(path, cx)
})
.await
.unwrap();
ep_store.update(cx, |ep_store, cx| {
ep_store.register_buffer(&buffer, &project, cx);
});
// Case 1: Manual edits have `predicted` set to false.
buffer.update(cx, |buffer, cx| {
buffer.edit(vec![(0..6, "LINE ZERO")], None, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![indoc! {"
manual
@@ -1,4 +1,4 @@
-line 0
+LINE ZERO
line 1
line 2
line 3
"}]
);
// Case 2: Multiple successive manual edits near each other are merged into one
// event with `predicted` set to false.
buffer.update(cx, |buffer, cx| {
let offset = Point::new(1, 0).to_offset(buffer);
let end = Point::new(1, 6).to_offset(buffer);
buffer.edit(vec![(offset..end, "LINE ONE")], None, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![indoc! {"
manual
@@ -1,5 +1,5 @@
-line 0
-line 1
+LINE ZERO
+LINE ONE
line 2
line 3
line 4
"}]
);
// Case 3: Accepted predictions have `predicted` set to true.
// Case 5: A manual edit that follows a predicted edit is not merged with the
// predicted edit, even if it is nearby.
ep_store.update(cx, |ep_store, cx| {
buffer.update(cx, |buffer, cx| {
let offset = Point::new(2, 0).to_offset(buffer);
let end = Point::new(2, 6).to_offset(buffer);
buffer.edit(vec![(offset..end, "LINE TWO")], None, cx);
});
ep_store.report_changes_for_buffer(&buffer, &project, true, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![
indoc! {"
manual
@@ -1,5 +1,5 @@
-line 0
-line 1
+LINE ZERO
+LINE ONE
line 2
line 3
line 4
"},
indoc! {"
predicted
@@ -1,6 +1,6 @@
LINE ZERO
LINE ONE
-line 2
+LINE TWO
line 3
line 4
line 5
"}
]
);
// Case 4: Multiple successive accepted predictions near each other are merged
// into one event with `predicted` set to true.
ep_store.update(cx, |ep_store, cx| {
buffer.update(cx, |buffer, cx| {
let offset = Point::new(3, 0).to_offset(buffer);
let end = Point::new(3, 6).to_offset(buffer);
buffer.edit(vec![(offset..end, "LINE THREE")], None, cx);
});
ep_store.report_changes_for_buffer(&buffer, &project, true, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![
indoc! {"
manual
@@ -1,5 +1,5 @@
-line 0
-line 1
+LINE ZERO
+LINE ONE
line 2
line 3
line 4
"},
indoc! {"
predicted
@@ -1,7 +1,7 @@
LINE ZERO
LINE ONE
-line 2
-line 3
+LINE TWO
+LINE THREE
line 4
line 5
line 6
"}
]
);
// Case 5 (continued): A manual edit that follows a predicted edit is not merged
// with the predicted edit, even if it is nearby.
buffer.update(cx, |buffer, cx| {
let offset = Point::new(4, 0).to_offset(buffer);
let end = Point::new(4, 6).to_offset(buffer);
buffer.edit(vec![(offset..end, "LINE FOUR")], None, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![
indoc! {"
manual
@@ -1,5 +1,5 @@
-line 0
-line 1
+LINE ZERO
+LINE ONE
line 2
line 3
line 4
"},
indoc! {"
predicted
@@ -1,7 +1,7 @@
LINE ZERO
LINE ONE
-line 2
-line 3
+LINE TWO
+LINE THREE
line 4
line 5
line 6
"},
indoc! {"
manual
@@ -2,7 +2,7 @@
LINE ONE
LINE TWO
LINE THREE
-line 4
+LINE FOUR
line 5
line 6
line 7
"}
]
);
// Case 6: If we then perform a manual edit at a *different* location (more than
// 8 lines away), then the edits at the prior location can be merged with each
// other, even if some are predicted and some are not. `predicted` means all
// constituent edits were predicted.
buffer.update(cx, |buffer, cx| {
let offset = Point::new(14, 0).to_offset(buffer);
let end = Point::new(14, 7).to_offset(buffer);
buffer.edit(vec![(offset..end, "LINE FOURTEEN")], None, cx);
});
let events = ep_store.update(cx, |ep_store, cx| {
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(
render_events_with_predicted(&events),
vec![
indoc! {"
manual
@@ -1,8 +1,8 @@
-line 0
-line 1
-line 2
-line 3
-line 4
+LINE ZERO
+LINE ONE
+LINE TWO
+LINE THREE
+LINE FOUR
line 5
line 6
line 7
"},
indoc! {"
manual
@@ -12,4 +12,4 @@
line 11
line 12
line 13
-line 14
+LINE FOURTEEN
"}
]
);
}
#[gpui::test]
async fn test_empty_prediction(cx: &mut TestAppContext) {
let (ep_store, mut requests) = init_test_with_fake_client(cx);
@@ -1923,7 +2178,7 @@ fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) {
let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let diff = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap();
let (diff, _) = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap();
assert_eq!(
diff,
@@ -153,9 +153,7 @@ fn capture_example_as_markdown(
.read(cx)
.text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?;
let ep_store = EditPredictionStore::try_global(cx)?;
let events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx));
let example = capture_example(
project.clone(),
buffer,
+4 -4
View File
@@ -8023,10 +8023,6 @@ impl Editor {
match granularity {
EditPredictionGranularity::Full => {
if let Some(provider) = self.edit_prediction_provider() {
provider.accept(cx);
}
let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx);
// Compute fallback cursor position BEFORE applying the edit,
@@ -8040,6 +8036,10 @@ impl Editor {
buffer.edit(edits.iter().cloned(), None, cx)
});
if let Some(provider) = self.edit_prediction_provider() {
provider.accept(cx);
}
// Resolve cursor position after the edit is applied
let cursor_target = if let Some((anchor, offset)) = cursor_position {
// The anchor tracks through the edit, then we add the offset