mirror of
https://github.com/zed-industries/zed.git
synced 2024-10-23 06:56:33 +00:00
Refine inline transformation UX (#12939)
https://github.com/zed-industries/zed/assets/482957/1790e32e-1f59-4831-8a4c-722cf441e7e9 Release Notes: - N/A --------- Co-authored-by: Richard <richard@zed.dev> Co-authored-by: Nathan <nathan@zed.dev>
This commit is contained in:
parent
9e3c5f3e12
commit
e1f4dfc068
20 changed files with 419 additions and 219 deletions
|
@ -79,7 +79,6 @@ pub fn init(cx: &mut AppContext) {
|
|||
workspace.toggle_panel_focus::<AssistantPanel>(cx);
|
||||
})
|
||||
.register_action(AssistantPanel::inline_assist)
|
||||
.register_action(AssistantPanel::cancel_last_inline_assist)
|
||||
.register_action(ContextEditor::quote_selection);
|
||||
},
|
||||
)
|
||||
|
@ -421,19 +420,6 @@ impl AssistantPanel {
|
|||
}
|
||||
}
|
||||
|
||||
fn cancel_last_inline_assist(
|
||||
_workspace: &mut Workspace,
|
||||
_: &editor::actions::Cancel,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) {
|
||||
let canceled = InlineAssistant::update_global(cx, |assistant, cx| {
|
||||
assistant.cancel_last_inline_assist(cx)
|
||||
});
|
||||
if !canceled {
|
||||
cx.propagate();
|
||||
}
|
||||
}
|
||||
|
||||
fn new_context(&mut self, cx: &mut ViewContext<Self>) -> Option<View<ContextEditor>> {
|
||||
let workspace = self.workspace.upgrade()?;
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@ use editor::{
|
|||
};
|
||||
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
|
||||
use gpui::{
|
||||
AnyWindowHandle, AppContext, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight,
|
||||
Global, HighlightStyle, Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View,
|
||||
AppContext, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, Global,
|
||||
HighlightStyle, Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View,
|
||||
ViewContext, WeakView, WhiteSpace, WindowContext,
|
||||
};
|
||||
use language::{Buffer, Point, TransactionId};
|
||||
|
@ -34,6 +34,7 @@ use std::{
|
|||
};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, Tooltip};
|
||||
use util::RangeExt;
|
||||
use workspace::{notifications::NotificationId, Toast, Workspace};
|
||||
|
||||
pub fn init(telemetry: Arc<Telemetry>, cx: &mut AppContext) {
|
||||
|
@ -45,16 +46,11 @@ const PROMPT_HISTORY_MAX_LEN: usize = 20;
|
|||
pub struct InlineAssistant {
|
||||
next_assist_id: InlineAssistId,
|
||||
pending_assists: HashMap<InlineAssistId, PendingInlineAssist>,
|
||||
pending_assist_ids_by_editor: HashMap<WeakView<Editor>, EditorPendingAssists>,
|
||||
pending_assist_ids_by_editor: HashMap<WeakView<Editor>, Vec<InlineAssistId>>,
|
||||
prompt_history: VecDeque<String>,
|
||||
telemetry: Option<Arc<Telemetry>>,
|
||||
}
|
||||
|
||||
struct EditorPendingAssists {
|
||||
window: AnyWindowHandle,
|
||||
assist_ids: Vec<InlineAssistId>,
|
||||
}
|
||||
|
||||
impl Global for InlineAssistant {}
|
||||
|
||||
impl InlineAssistant {
|
||||
|
@ -103,7 +99,7 @@ impl InlineAssistant {
|
|||
}
|
||||
};
|
||||
|
||||
let inline_assist_id = self.next_assist_id.post_inc();
|
||||
let assist_id = self.next_assist_id.post_inc();
|
||||
let codegen = cx.new_model(|cx| {
|
||||
Codegen::new(
|
||||
editor.read(cx).buffer().clone(),
|
||||
|
@ -116,7 +112,7 @@ impl InlineAssistant {
|
|||
let gutter_dimensions = Arc::new(Mutex::new(GutterDimensions::default()));
|
||||
let prompt_editor = cx.new_view(|cx| {
|
||||
InlineAssistEditor::new(
|
||||
inline_assist_id,
|
||||
assist_id,
|
||||
gutter_dimensions.clone(),
|
||||
self.prompt_history.clone(),
|
||||
codegen.clone(),
|
||||
|
@ -164,7 +160,7 @@ impl InlineAssistant {
|
|||
});
|
||||
|
||||
self.pending_assists.insert(
|
||||
inline_assist_id,
|
||||
assist_id,
|
||||
PendingInlineAssist {
|
||||
include_context,
|
||||
editor: editor.downgrade(),
|
||||
|
@ -179,24 +175,35 @@ impl InlineAssistant {
|
|||
_subscriptions: vec![
|
||||
cx.subscribe(&prompt_editor, |inline_assist_editor, event, cx| {
|
||||
InlineAssistant::update_global(cx, |this, cx| {
|
||||
this.handle_inline_assistant_event(inline_assist_editor, event, cx)
|
||||
this.handle_inline_assistant_editor_event(
|
||||
inline_assist_editor,
|
||||
event,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}),
|
||||
cx.subscribe(editor, {
|
||||
let inline_assist_editor = prompt_editor.downgrade();
|
||||
move |editor, event, cx| {
|
||||
if let Some(inline_assist_editor) = inline_assist_editor.upgrade() {
|
||||
if let EditorEvent::SelectionsChanged { local } = event {
|
||||
if *local
|
||||
&& inline_assist_editor
|
||||
.focus_handle(cx)
|
||||
.contains_focused(cx)
|
||||
{
|
||||
cx.focus_view(&editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.update(cx, |editor, _cx| {
|
||||
editor.register_action(
|
||||
move |_: &editor::actions::Newline, cx: &mut WindowContext| {
|
||||
InlineAssistant::update_global(cx, |this, cx| {
|
||||
this.handle_editor_action(assist_id, false, cx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}),
|
||||
editor.update(cx, |editor, _cx| {
|
||||
editor.register_action(
|
||||
move |_: &editor::actions::Cancel, cx: &mut WindowContext| {
|
||||
InlineAssistant::update_global(cx, |this, cx| {
|
||||
this.handle_editor_action(assist_id, true, cx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}),
|
||||
cx.subscribe(editor, move |editor, event, cx| {
|
||||
InlineAssistant::update_global(cx, |this, cx| {
|
||||
this.handle_editor_event(assist_id, editor, event, cx)
|
||||
})
|
||||
}),
|
||||
cx.observe(&codegen, {
|
||||
let editor = editor.downgrade();
|
||||
|
@ -204,19 +211,17 @@ impl InlineAssistant {
|
|||
if let Some(editor) = editor.upgrade() {
|
||||
InlineAssistant::update_global(cx, |this, cx| {
|
||||
this.update_editor_highlights(&editor, cx);
|
||||
this.update_editor_blocks(&editor, inline_assist_id, cx);
|
||||
this.update_editor_blocks(&editor, assist_id, cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
cx.subscribe(&codegen, move |codegen, event, cx| {
|
||||
InlineAssistant::update_global(cx, |this, cx| match event {
|
||||
CodegenEvent::Undone => {
|
||||
this.finish_inline_assist(inline_assist_id, false, cx)
|
||||
}
|
||||
CodegenEvent::Undone => this.finish_inline_assist(assist_id, false, cx),
|
||||
CodegenEvent::Finished => {
|
||||
let pending_assist = if let Some(pending_assist) =
|
||||
this.pending_assists.get(&inline_assist_id)
|
||||
this.pending_assists.get(&assist_id)
|
||||
{
|
||||
pending_assist
|
||||
} else {
|
||||
|
@ -238,7 +243,7 @@ impl InlineAssistant {
|
|||
let id = NotificationId::identified::<
|
||||
InlineAssistantError,
|
||||
>(
|
||||
inline_assist_id.0
|
||||
assist_id.0
|
||||
);
|
||||
|
||||
workspace.show_toast(Toast::new(id, error), cx);
|
||||
|
@ -248,7 +253,7 @@ impl InlineAssistant {
|
|||
}
|
||||
|
||||
if pending_assist.editor_decorations.is_none() {
|
||||
this.finish_inline_assist(inline_assist_id, false, cx);
|
||||
this.finish_inline_assist(assist_id, false, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -259,16 +264,12 @@ impl InlineAssistant {
|
|||
|
||||
self.pending_assist_ids_by_editor
|
||||
.entry(editor.downgrade())
|
||||
.or_insert_with(|| EditorPendingAssists {
|
||||
window: cx.window_handle(),
|
||||
assist_ids: Vec::new(),
|
||||
})
|
||||
.assist_ids
|
||||
.push(inline_assist_id);
|
||||
.or_default()
|
||||
.push(assist_id);
|
||||
self.update_editor_highlights(editor, cx);
|
||||
}
|
||||
|
||||
fn handle_inline_assistant_event(
|
||||
fn handle_inline_assistant_editor_event(
|
||||
&mut self,
|
||||
inline_assist_editor: View<InlineAssistEditor>,
|
||||
event: &InlineAssistEditorEvent,
|
||||
|
@ -289,7 +290,7 @@ impl InlineAssistant {
|
|||
self.finish_inline_assist(assist_id, true, cx);
|
||||
}
|
||||
InlineAssistEditorEvent::Dismissed => {
|
||||
self.hide_inline_assist_decorations(assist_id, cx);
|
||||
self.dismiss_inline_assist(assist_id, cx);
|
||||
}
|
||||
InlineAssistEditorEvent::Resized { height_in_lines } => {
|
||||
self.resize_inline_assist(assist_id, *height_in_lines, cx);
|
||||
|
@ -297,20 +298,87 @@ impl InlineAssistant {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn cancel_last_inline_assist(&mut self, cx: &mut WindowContext) -> bool {
|
||||
for (editor, pending_assists) in &self.pending_assist_ids_by_editor {
|
||||
if pending_assists.window == cx.window_handle() {
|
||||
if let Some(editor) = editor.upgrade() {
|
||||
if editor.read(cx).is_focused(cx) {
|
||||
if let Some(assist_id) = pending_assists.assist_ids.last().copied() {
|
||||
self.finish_inline_assist(assist_id, true, cx);
|
||||
return true;
|
||||
}
|
||||
fn handle_editor_action(
|
||||
&mut self,
|
||||
assist_id: InlineAssistId,
|
||||
undo: bool,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let Some(assist) = self.pending_assists.get(&assist_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(editor) = assist.editor.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
|
||||
let assist_range = assist.codegen.read(cx).range().to_offset(&buffer);
|
||||
let editor = editor.read(cx);
|
||||
if editor.selections.count() == 1 {
|
||||
let selection = editor.selections.newest::<usize>(cx);
|
||||
if assist_range.contains(&selection.start) && assist_range.contains(&selection.end) {
|
||||
if undo {
|
||||
self.finish_inline_assist(assist_id, true, cx);
|
||||
} else if matches!(assist.codegen.read(cx).status, CodegenStatus::Pending) {
|
||||
self.dismiss_inline_assist(assist_id, cx);
|
||||
} else {
|
||||
self.finish_inline_assist(assist_id, false, cx);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn handle_editor_event(
|
||||
&mut self,
|
||||
assist_id: InlineAssistId,
|
||||
editor: View<Editor>,
|
||||
event: &EditorEvent,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let Some(assist) = self.pending_assists.get(&assist_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match event {
|
||||
EditorEvent::SelectionsChanged { local } if *local => {
|
||||
if let Some(decorations) = assist.editor_decorations.as_ref() {
|
||||
if decorations
|
||||
.prompt_editor
|
||||
.focus_handle(cx)
|
||||
.contains_focused(cx)
|
||||
{
|
||||
cx.focus_view(&editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorEvent::Saved => {
|
||||
if let CodegenStatus::Done = &assist.codegen.read(cx).status {
|
||||
self.finish_inline_assist(assist_id, false, cx)
|
||||
}
|
||||
}
|
||||
EditorEvent::Edited { transaction_id }
|
||||
if matches!(
|
||||
assist.codegen.read(cx).status,
|
||||
CodegenStatus::Error(_) | CodegenStatus::Done
|
||||
) =>
|
||||
{
|
||||
let buffer = editor.read(cx).buffer().read(cx);
|
||||
let edited_ranges =
|
||||
buffer.edited_ranges_for_transaction::<usize>(*transaction_id, cx);
|
||||
let assist_range = assist.codegen.read(cx).range().to_offset(&buffer.read(cx));
|
||||
if edited_ranges
|
||||
.iter()
|
||||
.any(|range| range.overlaps(&assist_range))
|
||||
{
|
||||
self.finish_inline_assist(assist_id, false, cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn finish_inline_assist(
|
||||
|
@ -319,15 +387,15 @@ impl InlineAssistant {
|
|||
undo: bool,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
self.hide_inline_assist_decorations(assist_id, cx);
|
||||
self.dismiss_inline_assist(assist_id, cx);
|
||||
|
||||
if let Some(pending_assist) = self.pending_assists.remove(&assist_id) {
|
||||
if let hash_map::Entry::Occupied(mut entry) = self
|
||||
.pending_assist_ids_by_editor
|
||||
.entry(pending_assist.editor.clone())
|
||||
{
|
||||
entry.get_mut().assist_ids.retain(|id| *id != assist_id);
|
||||
if entry.get().assist_ids.is_empty() {
|
||||
entry.get_mut().retain(|id| *id != assist_id);
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
@ -344,11 +412,7 @@ impl InlineAssistant {
|
|||
}
|
||||
}
|
||||
|
||||
fn hide_inline_assist_decorations(
|
||||
&mut self,
|
||||
assist_id: InlineAssistId,
|
||||
cx: &mut WindowContext,
|
||||
) -> bool {
|
||||
fn dismiss_inline_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
|
||||
let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) else {
|
||||
return false;
|
||||
};
|
||||
|
@ -558,16 +622,14 @@ impl InlineAssistant {
|
|||
let mut gutter_transformed_ranges = Vec::new();
|
||||
let mut foreground_ranges = Vec::new();
|
||||
let mut inserted_row_ranges = Vec::new();
|
||||
let empty_inline_assist_ids = Vec::new();
|
||||
let inline_assist_ids = self
|
||||
let empty_assist_ids = Vec::new();
|
||||
let assist_ids = self
|
||||
.pending_assist_ids_by_editor
|
||||
.get(&editor.downgrade())
|
||||
.map_or(&empty_inline_assist_ids, |pending_assists| {
|
||||
&pending_assists.assist_ids
|
||||
});
|
||||
.unwrap_or(&empty_assist_ids);
|
||||
|
||||
for inline_assist_id in inline_assist_ids {
|
||||
if let Some(pending_assist) = self.pending_assists.get(inline_assist_id) {
|
||||
for assist_id in assist_ids {
|
||||
if let Some(pending_assist) = self.pending_assists.get(assist_id) {
|
||||
let codegen = pending_assist.codegen.read(cx);
|
||||
foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned());
|
||||
|
||||
|
@ -1025,7 +1087,7 @@ impl InlineAssistEditor {
|
|||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EditorEvent::Edited => {
|
||||
EditorEvent::Edited { .. } => {
|
||||
let prompt = self.prompt_editor.read(cx).text(cx);
|
||||
if self
|
||||
.prompt_history_ix
|
||||
|
|
|
@ -592,19 +592,6 @@ impl PromptLibrary {
|
|||
}
|
||||
}
|
||||
|
||||
fn cancel_last_inline_assist(
|
||||
&mut self,
|
||||
_: &editor::actions::Cancel,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let canceled = InlineAssistant::update_global(cx, |assistant, cx| {
|
||||
assistant.cancel_last_inline_assist(cx)
|
||||
});
|
||||
if !canceled {
|
||||
cx.propagate();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_prompt_editor_event(
|
||||
&mut self,
|
||||
prompt_id: PromptId,
|
||||
|
@ -743,7 +730,6 @@ impl PromptLibrary {
|
|||
div()
|
||||
.on_action(cx.listener(Self::focus_picker))
|
||||
.on_action(cx.listener(Self::inline_assist))
|
||||
.on_action(cx.listener(Self::cancel_last_inline_assist))
|
||||
.flex_grow()
|
||||
.h_full()
|
||||
.pt(Spacing::XXLarge.rems(cx))
|
||||
|
|
|
@ -232,7 +232,7 @@ impl ChannelView {
|
|||
this.focus_position_from_link(position.clone(), false, cx);
|
||||
this._reparse_subscription.take();
|
||||
}
|
||||
EditorEvent::Edited | EditorEvent::SelectionsChanged { local: true } => {
|
||||
EditorEvent::Edited { .. } | EditorEvent::SelectionsChanged { local: true } => {
|
||||
this._reparse_subscription.take();
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
@ -116,15 +116,16 @@ use serde::{Deserialize, Serialize};
|
|||
use settings::{update_settings_file, Settings, SettingsStore};
|
||||
use smallvec::SmallVec;
|
||||
use snippet::Snippet;
|
||||
use std::ops::Not as _;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
cmp::{self, Ordering, Reverse},
|
||||
mem,
|
||||
num::NonZeroU32,
|
||||
ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive},
|
||||
ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive},
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
@ -377,6 +378,19 @@ impl Default for EditorStyle {
|
|||
|
||||
type CompletionId = usize;
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
|
||||
struct EditorActionId(usize);
|
||||
|
||||
impl EditorActionId {
|
||||
pub fn post_inc(&mut self) -> Self {
|
||||
let answer = self.0;
|
||||
|
||||
*self = Self(answer + 1);
|
||||
|
||||
Self(answer)
|
||||
}
|
||||
}
|
||||
|
||||
// type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
|
||||
// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
|
||||
|
||||
|
@ -512,7 +526,8 @@ pub struct Editor {
|
|||
gutter_dimensions: GutterDimensions,
|
||||
pub vim_replace_map: HashMap<Range<usize>, String>,
|
||||
style: Option<EditorStyle>,
|
||||
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
|
||||
next_editor_action_id: EditorActionId,
|
||||
editor_actions: Rc<RefCell<BTreeMap<EditorActionId, Box<dyn Fn(&mut ViewContext<Self>)>>>>,
|
||||
use_autoclose: bool,
|
||||
auto_replace_emoji_shortcode: bool,
|
||||
show_git_blame_gutter: bool,
|
||||
|
@ -1805,7 +1820,8 @@ impl Editor {
|
|||
style: None,
|
||||
show_cursor_names: false,
|
||||
hovered_cursors: Default::default(),
|
||||
editor_actions: Default::default(),
|
||||
next_editor_action_id: EditorActionId::default(),
|
||||
editor_actions: Rc::default(),
|
||||
vim_replace_map: Default::default(),
|
||||
show_inline_completions: mode == EditorMode::Full,
|
||||
custom_context_menu: None,
|
||||
|
@ -6448,29 +6464,9 @@ impl Editor {
|
|||
return;
|
||||
}
|
||||
|
||||
if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
|
||||
if let Some((selections, _)) = self.selection_history.transaction(tx_id).cloned() {
|
||||
self.change_selections(None, cx, |s| {
|
||||
s.select_anchors(selections.to_vec());
|
||||
});
|
||||
}
|
||||
self.request_autoscroll(Autoscroll::fit(), cx);
|
||||
self.unmark_text(cx);
|
||||
self.refresh_inline_completion(true, cx);
|
||||
cx.emit(EditorEvent::Edited);
|
||||
cx.emit(EditorEvent::TransactionUndone {
|
||||
transaction_id: tx_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, _: &Redo, cx: &mut ViewContext<Self>) {
|
||||
if self.read_only(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
|
||||
if let Some((_, Some(selections))) = self.selection_history.transaction(tx_id).cloned()
|
||||
if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
|
||||
if let Some((selections, _)) =
|
||||
self.selection_history.transaction(transaction_id).cloned()
|
||||
{
|
||||
self.change_selections(None, cx, |s| {
|
||||
s.select_anchors(selections.to_vec());
|
||||
|
@ -6479,7 +6475,28 @@ impl Editor {
|
|||
self.request_autoscroll(Autoscroll::fit(), cx);
|
||||
self.unmark_text(cx);
|
||||
self.refresh_inline_completion(true, cx);
|
||||
cx.emit(EditorEvent::Edited);
|
||||
cx.emit(EditorEvent::Edited { transaction_id });
|
||||
cx.emit(EditorEvent::TransactionUndone { transaction_id });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, _: &Redo, cx: &mut ViewContext<Self>) {
|
||||
if self.read_only(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
|
||||
if let Some((_, Some(selections))) =
|
||||
self.selection_history.transaction(transaction_id).cloned()
|
||||
{
|
||||
self.change_selections(None, cx, |s| {
|
||||
s.select_anchors(selections.to_vec());
|
||||
});
|
||||
}
|
||||
self.request_autoscroll(Autoscroll::fit(), cx);
|
||||
self.unmark_text(cx);
|
||||
self.refresh_inline_completion(true, cx);
|
||||
cx.emit(EditorEvent::Edited { transaction_id });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9590,18 +9607,20 @@ impl Editor {
|
|||
now: Instant,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<TransactionId> {
|
||||
if let Some(tx_id) = self
|
||||
if let Some(transaction_id) = self
|
||||
.buffer
|
||||
.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx))
|
||||
{
|
||||
if let Some((_, end_selections)) = self.selection_history.transaction_mut(tx_id) {
|
||||
if let Some((_, end_selections)) =
|
||||
self.selection_history.transaction_mut(transaction_id)
|
||||
{
|
||||
*end_selections = Some(self.selections.disjoint_anchors());
|
||||
} else {
|
||||
log::error!("unexpectedly ended a transaction that wasn't started by this editor");
|
||||
}
|
||||
|
||||
cx.emit(EditorEvent::Edited);
|
||||
Some(tx_id)
|
||||
cx.emit(EditorEvent::Edited { transaction_id });
|
||||
Some(transaction_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
@ -11293,21 +11312,28 @@ impl Editor {
|
|||
pub fn register_action<A: Action>(
|
||||
&mut self,
|
||||
listener: impl Fn(&A, &mut WindowContext) + 'static,
|
||||
) -> &mut Self {
|
||||
) -> Subscription {
|
||||
let id = self.next_editor_action_id.post_inc();
|
||||
let listener = Arc::new(listener);
|
||||
self.editor_actions.borrow_mut().insert(
|
||||
id,
|
||||
Box::new(move |cx| {
|
||||
let _view = cx.view().clone();
|
||||
let cx = cx.window_context();
|
||||
let listener = listener.clone();
|
||||
cx.on_action(TypeId::of::<A>(), move |action, phase, cx| {
|
||||
let action = action.downcast_ref().unwrap();
|
||||
if phase == DispatchPhase::Bubble {
|
||||
listener(action, cx)
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
self.editor_actions.push(Box::new(move |cx| {
|
||||
let _view = cx.view().clone();
|
||||
let cx = cx.window_context();
|
||||
let listener = listener.clone();
|
||||
cx.on_action(TypeId::of::<A>(), move |action, phase, cx| {
|
||||
let action = action.downcast_ref().unwrap();
|
||||
if phase == DispatchPhase::Bubble {
|
||||
listener(action, cx)
|
||||
}
|
||||
})
|
||||
}));
|
||||
self
|
||||
let editor_actions = self.editor_actions.clone();
|
||||
Subscription::new(move || {
|
||||
editor_actions.borrow_mut().remove(&id);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_header_size(&self) -> u8 {
|
||||
|
@ -11764,7 +11790,9 @@ pub enum EditorEvent {
|
|||
ids: Vec<ExcerptId>,
|
||||
},
|
||||
BufferEdited,
|
||||
Edited,
|
||||
Edited {
|
||||
transaction_id: clock::Lamport,
|
||||
},
|
||||
Reparsed,
|
||||
Focused,
|
||||
Blurred,
|
||||
|
|
|
@ -57,10 +57,10 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
let events = events.clone();
|
||||
|cx| {
|
||||
let view = cx.view().clone();
|
||||
cx.subscribe(&view, move |_, _, event: &EditorEvent, _| {
|
||||
if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) {
|
||||
events.borrow_mut().push(("editor1", event.clone()));
|
||||
}
|
||||
cx.subscribe(&view, move |_, _, event: &EditorEvent, _| match event {
|
||||
EditorEvent::Edited { .. } => events.borrow_mut().push(("editor1", "edited")),
|
||||
EditorEvent::BufferEdited => events.borrow_mut().push(("editor1", "buffer edited")),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
Editor::for_buffer(buffer.clone(), None, cx)
|
||||
|
@ -70,11 +70,16 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
let editor2 = cx.add_window({
|
||||
let events = events.clone();
|
||||
|cx| {
|
||||
cx.subscribe(&cx.view().clone(), move |_, _, event: &EditorEvent, _| {
|
||||
if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) {
|
||||
events.borrow_mut().push(("editor2", event.clone()));
|
||||
}
|
||||
})
|
||||
cx.subscribe(
|
||||
&cx.view().clone(),
|
||||
move |_, _, event: &EditorEvent, _| match event {
|
||||
EditorEvent::Edited { .. } => events.borrow_mut().push(("editor2", "edited")),
|
||||
EditorEvent::BufferEdited => {
|
||||
events.borrow_mut().push(("editor2", "buffer edited"))
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
Editor::for_buffer(buffer.clone(), None, cx)
|
||||
}
|
||||
|
@ -87,9 +92,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor1", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor1", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -98,9 +103,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor2", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor2", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -109,9 +114,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor1", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor1", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -120,9 +125,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor1", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor1", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -131,9 +136,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor2", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor2", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -142,9 +147,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
mem::take(&mut *events.borrow_mut()),
|
||||
[
|
||||
("editor2", EditorEvent::Edited),
|
||||
("editor1", EditorEvent::BufferEdited),
|
||||
("editor2", EditorEvent::BufferEdited),
|
||||
("editor2", "edited"),
|
||||
("editor1", "buffer edited"),
|
||||
("editor2", "buffer edited"),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -153,7 +153,7 @@ impl EditorElement {
|
|||
fn register_actions(&self, cx: &mut WindowContext) {
|
||||
let view = &self.editor;
|
||||
view.update(cx, |editor, cx| {
|
||||
for action in editor.editor_actions.iter() {
|
||||
for action in editor.editor_actions.borrow().values() {
|
||||
(action)(cx)
|
||||
}
|
||||
});
|
||||
|
|
|
@ -615,32 +615,36 @@ fn editor_with_deleted_text(
|
|||
]);
|
||||
let original_multi_buffer_range = hunk.multi_buffer_range.clone();
|
||||
let diff_base_range = hunk.diff_base_byte_range.clone();
|
||||
editor.register_action::<RevertSelectedHunks>(move |_, cx| {
|
||||
parent_editor
|
||||
.update(cx, |editor, cx| {
|
||||
let Some((buffer, original_text)) = editor.buffer().update(cx, |buffer, cx| {
|
||||
let (_, buffer, _) =
|
||||
buffer.excerpt_containing(original_multi_buffer_range.start, cx)?;
|
||||
let original_text =
|
||||
buffer.read(cx).diff_base()?.slice(diff_base_range.clone());
|
||||
Some((buffer, Arc::from(original_text.to_string())))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
Some((
|
||||
original_multi_buffer_range.start.text_anchor
|
||||
..original_multi_buffer_range.end.text_anchor,
|
||||
original_text,
|
||||
)),
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
editor
|
||||
.register_action::<RevertSelectedHunks>(move |_, cx| {
|
||||
parent_editor
|
||||
.update(cx, |editor, cx| {
|
||||
let Some((buffer, original_text)) =
|
||||
editor.buffer().update(cx, |buffer, cx| {
|
||||
let (_, buffer, _) = buffer
|
||||
.excerpt_containing(original_multi_buffer_range.start, cx)?;
|
||||
let original_text =
|
||||
buffer.read(cx).diff_base()?.slice(diff_base_range.clone());
|
||||
Some((buffer, Arc::from(original_text.to_string())))
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
Some((
|
||||
original_multi_buffer_range.start.text_anchor
|
||||
..original_multi_buffer_range.end.text_anchor,
|
||||
original_text,
|
||||
)),
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
editor
|
||||
});
|
||||
|
||||
|
|
|
@ -234,7 +234,7 @@ impl FollowableItem for Editor {
|
|||
|
||||
fn to_follow_event(event: &EditorEvent) -> Option<workspace::item::FollowEvent> {
|
||||
match event {
|
||||
EditorEvent::Edited => Some(FollowEvent::Unfollow),
|
||||
EditorEvent::Edited { .. } => Some(FollowEvent::Unfollow),
|
||||
EditorEvent::SelectionsChanged { local }
|
||||
| EditorEvent::ScrollPositionChanged { local, .. } => {
|
||||
if *local {
|
||||
|
|
|
@ -773,7 +773,7 @@ impl ExtensionsPage {
|
|||
event: &editor::EditorEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let editor::EditorEvent::Edited = event {
|
||||
if let editor::EditorEvent::Edited { .. } = event {
|
||||
self.query_contains_error = false;
|
||||
self.fetch_extensions_debounced(cx);
|
||||
}
|
||||
|
|
|
@ -193,7 +193,7 @@ impl FeedbackModal {
|
|||
});
|
||||
|
||||
cx.subscribe(&feedback_editor, |this, editor, event: &EditorEvent, cx| {
|
||||
if *event == EditorEvent::Edited {
|
||||
if matches!(event, EditorEvent::Edited { .. }) {
|
||||
this.character_count = editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
|
|
|
@ -42,17 +42,19 @@ enum GoToLineRowHighlights {}
|
|||
impl GoToLine {
|
||||
fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
|
||||
let handle = cx.view().downgrade();
|
||||
editor.register_action(move |_: &Toggle, cx| {
|
||||
let Some(editor) = handle.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(workspace) = editor.read(cx).workspace() else {
|
||||
return;
|
||||
};
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
|
||||
editor
|
||||
.register_action(move |_: &Toggle, cx| {
|
||||
let Some(editor) = handle.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(workspace) = editor.read(cx).workspace() else {
|
||||
return;
|
||||
};
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
|
||||
})
|
||||
})
|
||||
});
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
|
||||
|
|
|
@ -154,6 +154,14 @@ pub struct Subscription {
|
|||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Creates a new subscription with a callback that gets invoked when
|
||||
/// this subscription is dropped.
|
||||
pub fn new(unsubscribe: impl 'static + FnOnce()) -> Self {
|
||||
Self {
|
||||
unsubscribe: Some(Box::new(unsubscribe)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the subscription from this handle. The callback will
|
||||
/// continue to be invoked until the views or models it has been
|
||||
/// subscribed to are dropped
|
||||
|
|
|
@ -294,7 +294,7 @@ impl MarkdownPreviewView {
|
|||
|
||||
let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
|
||||
match event {
|
||||
EditorEvent::Edited => {
|
||||
EditorEvent::Edited { .. } => {
|
||||
this.parse_markdown_from_active_editor(true, cx);
|
||||
}
|
||||
EditorEvent::SelectionsChanged { .. } => {
|
||||
|
|
|
@ -789,6 +789,68 @@ impl MultiBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn edited_ranges_for_transaction<D>(
|
||||
&self,
|
||||
transaction_id: TransactionId,
|
||||
cx: &AppContext,
|
||||
) -> Vec<Range<D>>
|
||||
where
|
||||
D: TextDimension + Ord + Sub<D, Output = D>,
|
||||
{
|
||||
if let Some(buffer) = self.as_singleton() {
|
||||
return buffer
|
||||
.read(cx)
|
||||
.edited_ranges_for_transaction_id(transaction_id)
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
|
||||
let Some(transaction) = self.history.transaction(transaction_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
let snapshot = self.read(cx);
|
||||
let buffers = self.buffers.borrow();
|
||||
let mut cursor = snapshot.excerpts.cursor::<ExcerptSummary>();
|
||||
|
||||
for (buffer_id, buffer_transaction) in &transaction.buffer_transactions {
|
||||
let Some(buffer_state) = buffers.get(&buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let buffer = buffer_state.buffer.read(cx);
|
||||
for range in buffer.edited_ranges_for_transaction_id::<D>(*buffer_transaction) {
|
||||
for excerpt_id in &buffer_state.excerpts {
|
||||
cursor.seek(excerpt_id, Bias::Left, &());
|
||||
if let Some(excerpt) = cursor.item() {
|
||||
if excerpt.locator == *excerpt_id {
|
||||
let excerpt_buffer_start =
|
||||
excerpt.range.context.start.summary::<D>(buffer);
|
||||
let excerpt_buffer_end = excerpt.range.context.end.summary::<D>(buffer);
|
||||
let excerpt_range = excerpt_buffer_start.clone()..excerpt_buffer_end;
|
||||
if excerpt_range.contains(&range.start)
|
||||
&& excerpt_range.contains(&range.end)
|
||||
{
|
||||
let excerpt_start = D::from_text_summary(&cursor.start().text);
|
||||
|
||||
let mut start = excerpt_start.clone();
|
||||
start.add_assign(&(range.start - excerpt_buffer_start.clone()));
|
||||
let mut end = excerpt_start;
|
||||
end.add_assign(&(range.end - excerpt_buffer_start));
|
||||
|
||||
ranges.push(start..end);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ranges.sort_by_key(|range| range.start.clone());
|
||||
ranges
|
||||
}
|
||||
|
||||
pub fn merge_transactions(
|
||||
&mut self,
|
||||
transaction: TransactionId,
|
||||
|
@ -3968,6 +4030,17 @@ impl History {
|
|||
}
|
||||
}
|
||||
|
||||
fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
|
||||
self.undo_stack
|
||||
.iter()
|
||||
.find(|transaction| transaction.id == transaction_id)
|
||||
.or_else(|| {
|
||||
self.redo_stack
|
||||
.iter()
|
||||
.find(|transaction| transaction.id == transaction_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> {
|
||||
self.undo_stack
|
||||
.iter_mut()
|
||||
|
@ -6060,6 +6133,15 @@ mod tests {
|
|||
multibuffer.end_transaction_at(now, cx);
|
||||
assert_eq!(multibuffer.read(cx).text(), "AB1234\nAB5678");
|
||||
|
||||
// Verify edited ranges for transaction 1
|
||||
assert_eq!(
|
||||
multibuffer.edited_ranges_for_transaction(transaction_1, cx),
|
||||
&[
|
||||
Point::new(0, 0)..Point::new(0, 2),
|
||||
Point::new(1, 0)..Point::new(1, 2)
|
||||
]
|
||||
);
|
||||
|
||||
// Edit buffer 1 through the multibuffer
|
||||
now += 2 * group_interval;
|
||||
multibuffer.start_transaction_at(now, cx);
|
||||
|
|
|
@ -68,11 +68,13 @@ impl OutlineView {
|
|||
fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
|
||||
if editor.mode() == EditorMode::Full {
|
||||
let handle = cx.view().downgrade();
|
||||
editor.register_action(move |action, cx| {
|
||||
if let Some(editor) = handle.upgrade() {
|
||||
toggle(editor, action, cx);
|
||||
}
|
||||
});
|
||||
editor
|
||||
.register_action(move |action, cx| {
|
||||
if let Some(editor) = handle.upgrade() {
|
||||
toggle(editor, action, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -811,7 +811,7 @@ impl BufferSearchBar {
|
|||
match event {
|
||||
editor::EditorEvent::Focused => self.query_editor_focused = true,
|
||||
editor::EditorEvent::Blurred => self.query_editor_focused = false,
|
||||
editor::EditorEvent::Edited => {
|
||||
editor::EditorEvent::Edited { .. } => {
|
||||
self.clear_matches(cx);
|
||||
let search = self.update_matches(cx);
|
||||
|
||||
|
|
|
@ -356,6 +356,19 @@ impl History {
|
|||
}
|
||||
}
|
||||
|
||||
fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
|
||||
let entry = self
|
||||
.undo_stack
|
||||
.iter()
|
||||
.rfind(|entry| entry.transaction.id == transaction_id)
|
||||
.or_else(|| {
|
||||
self.redo_stack
|
||||
.iter()
|
||||
.rfind(|entry| entry.transaction.id == transaction_id)
|
||||
})?;
|
||||
Some(&entry.transaction)
|
||||
}
|
||||
|
||||
fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> {
|
||||
let entry = self
|
||||
.undo_stack
|
||||
|
@ -1389,6 +1402,19 @@ impl Buffer {
|
|||
self.history.finalize_last_transaction();
|
||||
}
|
||||
|
||||
pub fn edited_ranges_for_transaction_id<D>(
|
||||
&self,
|
||||
transaction_id: TransactionId,
|
||||
) -> impl '_ + Iterator<Item = Range<D>>
|
||||
where
|
||||
D: TextDimension,
|
||||
{
|
||||
self.history
|
||||
.transaction(transaction_id)
|
||||
.into_iter()
|
||||
.flat_map(|transaction| self.edited_ranges_for_transaction(transaction))
|
||||
}
|
||||
|
||||
pub fn edited_ranges_for_transaction<'a, D>(
|
||||
&'a self,
|
||||
transaction: &'a Transaction,
|
||||
|
|
|
@ -271,7 +271,9 @@ impl Vim {
|
|||
EditorEvent::TransactionUndone { transaction_id } => Vim::update(cx, |vim, cx| {
|
||||
vim.transaction_undone(transaction_id, cx);
|
||||
}),
|
||||
EditorEvent::Edited => Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx)),
|
||||
EditorEvent::Edited { .. } => {
|
||||
Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx))
|
||||
}
|
||||
_ => {}
|
||||
}));
|
||||
|
||||
|
|
|
@ -74,23 +74,30 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut ViewContex
|
|||
editor.show_inline_completion(&Default::default(), cx);
|
||||
},
|
||||
))
|
||||
.detach();
|
||||
editor
|
||||
.register_action(cx.listener(
|
||||
|editor, _: &copilot::NextSuggestion, cx: &mut ViewContext<Editor>| {
|
||||
editor.next_inline_completion(&Default::default(), cx);
|
||||
},
|
||||
))
|
||||
.detach();
|
||||
editor
|
||||
.register_action(cx.listener(
|
||||
|editor, _: &copilot::PreviousSuggestion, cx: &mut ViewContext<Editor>| {
|
||||
editor.previous_inline_completion(&Default::default(), cx);
|
||||
},
|
||||
))
|
||||
.detach();
|
||||
editor
|
||||
.register_action(cx.listener(
|
||||
|editor,
|
||||
_: &editor::actions::AcceptPartialCopilotSuggestion,
|
||||
cx: &mut ViewContext<Editor>| {
|
||||
editor.accept_partial_inline_completion(&Default::default(), cx);
|
||||
},
|
||||
));
|
||||
))
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn assign_inline_completion_provider(
|
||||
|
|
Loading…
Reference in a new issue