zeta2: Allow provider to suggest edits in different files (#39110)

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Bennet Bo Fenner
2025-09-29 15:48:58 +00:00
committed by GitHub
co-authored by Agus Zubiaga
parent b7f9fd7d74
commit cda48a3a1c
16 changed files with 689 additions and 318 deletions
+4 -7
View File
@@ -2,7 +2,6 @@ use edit_prediction::EditPredictionProvider;
use gpui::{Entity, prelude::*};
use indoc::indoc;
use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint};
use project::Project;
use std::ops::Range;
use text::{Point, ToOffset};
@@ -261,7 +260,7 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui:
EditPrediction::Edit { .. } => {
// This is expected for non-Zed providers
}
EditPrediction::Move { .. } => {
EditPrediction::MoveWithin { .. } | EditPrediction::MoveOutside { .. } => {
panic!(
"Non-Zed providers should not show Move predictions (jump functionality)"
);
@@ -299,7 +298,7 @@ fn assert_editor_active_move_completion(
.as_ref()
.expect("editor has no active completion");
if let EditPrediction::Move { target, .. } = &completion_state.completion {
if let EditPrediction::MoveWithin { target, .. } = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), *target);
} else {
panic!("expected move completion");
@@ -326,7 +325,7 @@ fn propose_edits<T: ToOffset>(
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local {
id: None,
edits: edits.collect(),
edit_preview: None,
@@ -357,7 +356,7 @@ fn propose_edits_non_zed<T: ToOffset>(
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local {
id: None,
edits: edits.collect(),
edit_preview: None,
@@ -418,7 +417,6 @@ impl EditPredictionProvider for FakeEditPredictionProvider {
fn refresh(
&mut self,
_project: Option<Entity<Project>>,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_debounce: bool,
@@ -492,7 +490,6 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider {
fn refresh(
&mut self,
_project: Option<Entity<Project>>,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_debounce: bool,
+152 -39
View File
@@ -638,17 +638,23 @@ enum EditPrediction {
display_mode: EditDisplayMode,
snapshot: BufferSnapshot,
},
Move {
/// Move to a specific location in the active editor
MoveWithin {
target: Anchor,
snapshot: BufferSnapshot,
},
/// Move to a specific location in a different editor (not the active one)
MoveOutside {
target: language::Anchor,
snapshot: BufferSnapshot,
},
}
struct EditPredictionState {
inlay_ids: Vec<InlayId>,
completion: EditPrediction,
completion_id: Option<SharedString>,
invalidation_range: Range<Anchor>,
invalidation_range: Option<Range<Anchor>>,
}
enum EditPredictionSettings {
@@ -7175,13 +7181,7 @@ impl Editor {
return None;
}
provider.refresh(
self.project.clone(),
buffer,
cursor_buffer_position,
debounce,
cx,
);
provider.refresh(buffer, cursor_buffer_position, debounce, cx);
Some(())
}
@@ -7424,10 +7424,8 @@ impl Editor {
return;
};
self.report_edit_prediction_event(active_edit_prediction.completion_id.clone(), true, cx);
match &active_edit_prediction.completion {
EditPrediction::Move { target, .. } => {
EditPrediction::MoveWithin { target, .. } => {
let target = *target;
if let Some(position_map) = &self.last_position_map {
@@ -7469,7 +7467,19 @@ impl Editor {
}
}
}
EditPrediction::MoveOutside { snapshot, target } => {
if let Some(workspace) = self.workspace() {
Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx)
.detach_and_log_err(cx);
}
}
EditPrediction::Edit { edits, .. } => {
self.report_edit_prediction_event(
active_edit_prediction.completion_id.clone(),
true,
cx,
);
if let Some(provider) = self.edit_prediction_provider() {
provider.accept(cx);
}
@@ -7522,10 +7532,8 @@ impl Editor {
return;
}
self.report_edit_prediction_event(active_edit_prediction.completion_id.clone(), true, cx);
match &active_edit_prediction.completion {
EditPrediction::Move { target, .. } => {
EditPrediction::MoveWithin { target, .. } => {
let target = *target;
self.change_selections(
SelectionEffects::scroll(Autoscroll::newest()),
@@ -7536,7 +7544,19 @@ impl Editor {
},
);
}
EditPrediction::MoveOutside { snapshot, target } => {
if let Some(workspace) = self.workspace() {
Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx)
.detach_and_log_err(cx);
}
}
EditPrediction::Edit { edits, .. } => {
self.report_edit_prediction_event(
active_edit_prediction.completion_id.clone(),
true,
cx,
);
// Find an insertion that starts at the cursor position.
let snapshot = self.buffer.read(cx).snapshot(cx);
let cursor_offset = self.selections.newest::<usize>(cx).head();
@@ -7631,6 +7651,36 @@ impl Editor {
);
}
fn open_editor_at_anchor(
snapshot: &language::BufferSnapshot,
target: language::Anchor,
workspace: &Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
workspace.update(cx, |workspace, cx| {
let path = snapshot.file().map(|file| file.full_path(cx));
let Some(path) =
path.and_then(|path| workspace.project().read(cx).find_project_path(path, cx))
else {
return Task::ready(Err(anyhow::anyhow!("Project path not found")));
};
let target = text::ToPoint::to_point(&target, snapshot);
let item = workspace.open_path(path, None, true, window, cx);
window.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
editor
.update_in(cx, |editor, window, cx| {
editor.go_to_singleton_buffer_point(target, window, cx);
})
.ok();
anyhow::Ok(())
})
})
}
pub fn has_active_edit_prediction(&self) -> bool {
self.active_edit_prediction.is_some()
}
@@ -7846,7 +7896,10 @@ impl Editor {
.active_edit_prediction
.as_ref()
.is_some_and(|completion| {
let invalidation_range = completion.invalidation_range.to_offset(&multibuffer);
let Some(invalidation_range) = completion.invalidation_range.as_ref() else {
return false;
};
let invalidation_range = invalidation_range.to_offset(&multibuffer);
let invalidation_range = invalidation_range.start..=invalidation_range.end;
!invalidation_range.contains(&offset_selection.head())
})
@@ -7882,8 +7935,31 @@ impl Editor {
}
let edit_prediction = provider.suggest(&buffer, cursor_buffer_position, cx)?;
let edits = edit_prediction
.edits
let (completion_id, edits, edit_preview) = match edit_prediction {
edit_prediction::EditPrediction::Local {
id,
edits,
edit_preview,
} => (id, edits, edit_preview),
edit_prediction::EditPrediction::Jump {
id,
snapshot,
target,
} => {
self.stale_edit_prediction_in_menu = None;
self.active_edit_prediction = Some(EditPredictionState {
inlay_ids: vec![],
completion: EditPrediction::MoveOutside { snapshot, target },
completion_id: id,
invalidation_range: None,
});
cx.notify();
return Some(());
}
};
let edits = edits
.into_iter()
.flat_map(|(range, new_text)| {
let start = multibuffer.anchor_in_excerpt(excerpt_id, range.start)?;
@@ -7928,7 +8004,7 @@ impl Editor {
invalidation_row_range =
move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row);
let target = first_edit_start;
EditPrediction::Move { target, snapshot }
EditPrediction::MoveWithin { target, snapshot }
} else {
let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true)
&& !self.edit_predictions_hidden_for_vim_mode;
@@ -7977,7 +8053,7 @@ impl Editor {
EditPrediction::Edit {
edits,
edit_preview: edit_prediction.edit_preview,
edit_preview,
display_mode,
snapshot,
}
@@ -7994,8 +8070,8 @@ impl Editor {
self.active_edit_prediction = Some(EditPredictionState {
inlay_ids,
completion,
completion_id: edit_prediction.id,
invalidation_range,
completion_id,
invalidation_range: Some(invalidation_range),
});
cx.notify();
@@ -8581,7 +8657,7 @@ impl Editor {
}
match &active_edit_prediction.completion {
EditPrediction::Move { target, .. } => {
EditPrediction::MoveWithin { target, .. } => {
let target_display_point = target.to_display_point(editor_snapshot);
if self.edit_prediction_requires_modifier() {
@@ -8666,6 +8742,28 @@ impl Editor {
window,
cx,
),
EditPrediction::MoveOutside { snapshot, .. } => {
let file_name = snapshot
.file()
.map(|file| file.file_name(cx))
.unwrap_or("untitled");
let mut element = self
.render_edit_prediction_line_popover(
format!("Jump to {file_name}"),
Some(IconName::ZedPredict),
window,
cx,
)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
let origin_x = text_bounds.size.width / 2. - size.width / 2.;
let origin_y = text_bounds.size.height - size.height - px(30.);
let origin = text_bounds.origin + gpui::Point::new(origin_x, origin_y);
element.prepaint_at(origin, window, cx);
Some((element, origin))
}
}
}
@@ -8730,13 +8828,13 @@ impl Editor {
.items_end()
.when(flag_on_right, |el| el.items_start())
.child(if flag_on_right {
self.render_edit_prediction_line_popover("Jump", None, window, cx)?
self.render_edit_prediction_line_popover("Jump", None, window, cx)
.rounded_bl(px(0.))
.rounded_tl(px(0.))
.border_l_2()
.border_color(border_color)
} else {
self.render_edit_prediction_line_popover("Jump", None, window, cx)?
self.render_edit_prediction_line_popover("Jump", None, window, cx)
.rounded_br(px(0.))
.rounded_tr(px(0.))
.border_r_2()
@@ -8776,7 +8874,7 @@ impl Editor {
cx: &mut App,
) -> Option<(AnyElement, gpui::Point<Pixels>)> {
let mut element = self
.render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx)?
.render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -8816,7 +8914,7 @@ impl Editor {
Some(IconName::ArrowUp),
window,
cx,
)?
)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -8835,7 +8933,7 @@ impl Editor {
Some(IconName::ArrowDown),
window,
cx,
)?
)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -8882,7 +8980,7 @@ impl Editor {
);
let mut element = self
.render_edit_prediction_line_popover(label, None, window, cx)?
.render_edit_prediction_line_popover(label, None, window, cx)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -8909,7 +9007,7 @@ impl Editor {
};
element = self
.render_edit_prediction_line_popover(label, Some(icon), window, cx)?
.render_edit_prediction_line_popover(label, Some(icon), window, cx)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -9163,13 +9261,13 @@ impl Editor {
icon: Option<IconName>,
window: &mut Window,
cx: &App,
) -> Option<Stateful<Div>> {
) -> Stateful<Div> {
let padding_right = if icon.is_some() { px(4.) } else { px(8.) };
let keybind = self.render_edit_prediction_accept_keybind(window, cx);
let has_keybind = keybind.is_some();
let result = h_flex()
h_flex()
.id("ep-line-popover")
.py_0p5()
.pl_1()
@@ -9215,9 +9313,7 @@ impl Editor {
.mt(px(1.5))
.child(Icon::new(icon).size(IconSize::Small)),
)
});
Some(result)
})
}
fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla {
@@ -9281,7 +9377,7 @@ impl Editor {
.rounded_tl(px(0.))
.overflow_hidden()
.child(div().px_1p5().child(match &prediction.completion {
EditPrediction::Move { target, snapshot } => {
EditPrediction::MoveWithin { target, snapshot } => {
use text::ToPoint as _;
if target.text_anchor.to_point(snapshot).row > cursor_point.row
{
@@ -9290,6 +9386,10 @@ impl Editor {
Icon::new(IconName::ZedPredictUp)
}
}
EditPrediction::MoveOutside { .. } => {
// TODO [zeta2] custom icon for external jump?
Icon::new(provider_icon)
}
EditPrediction::Edit { .. } => Icon::new(provider_icon),
}))
.child(
@@ -9472,7 +9572,7 @@ impl Editor {
.unwrap_or(true);
match &completion.completion {
EditPrediction::Move {
EditPrediction::MoveWithin {
target, snapshot, ..
} => {
if !supports_jump {
@@ -9494,7 +9594,20 @@ impl Editor {
.child(Label::new("Jump to Edit")),
)
}
EditPrediction::MoveOutside { snapshot, .. } => {
let file_name = snapshot
.file()
.map(|file| file.file_name(cx))
.unwrap_or("untitled");
Some(
h_flex()
.px_2()
.gap_2()
.flex_1()
.child(Icon::new(IconName::ZedPredict))
.child(Label::new(format!("Jump to {file_name}"))),
)
}
EditPrediction::Edit {
edits,
edit_preview,
@@ -21418,7 +21531,7 @@ impl Editor {
{
self.hide_context_menu(window, cx);
}
self.discard_edit_prediction(false, cx);
self.take_active_edit_prediction(cx);
cx.emit(EditorEvent::Blurred);
cx.notify();
}
+1 -1
View File
@@ -8272,7 +8272,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext)
cx.update(|_, cx| {
provider.update(cx, |provider, _| {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction {
provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local {
id: None,
edits: vec![(edit_position..edit_position, "X".into())],
edit_preview: None,