From 02078140c010f5518bc990dede574f996db6f43b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Sep 2023 11:25:37 +0200 Subject: [PATCH 01/28] Extract code generation logic into its own module --- crates/ai/src/ai.rs | 1 + crates/ai/src/assistant.rs | 555 ++++++------------------------ crates/ai/src/codegen.rs | 468 +++++++++++++++++++++++++ crates/editor/src/editor.rs | 10 +- crates/editor/src/multi_buffer.rs | 45 ++- 5 files changed, 609 insertions(+), 470 deletions(-) create mode 100644 crates/ai/src/codegen.rs diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 2c2d7e774e..2e8eca80e3 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,5 +1,6 @@ pub mod assistant; mod assistant_settings; +mod codegen; mod streaming_diff; use anyhow::{anyhow, Result}; diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 9b384252fc..1d56a6308c 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,9 +1,8 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel}, - stream_completion, - streaming_diff::{Hunk, StreamingDiff}, - MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, Role, - SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, + codegen::{self, Codegen, OpenAICompletionProvider}, + stream_completion, MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, + Role, SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; @@ -13,10 +12,10 @@ use editor::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, }, scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, ToPoint, + Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, }; use fs::Fs; -use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; +use futures::StreamExt; use gpui::{ actions, elements::{ @@ -30,17 +29,14 @@ use gpui::{ ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; -use language::{ - language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, ToOffset as _, - TransactionId, -}; +use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _}; use search::BufferSearchBar; use settings::SettingsStore; use std::{ cell::{Cell, RefCell}, cmp, env, fmt::Write, - future, iter, + iter, ops::Range, path::{Path, PathBuf}, rc::Rc, @@ -266,10 +262,22 @@ impl AssistantPanel { } fn new_inline_assist(&mut self, editor: &ViewHandle, cx: &mut ViewContext) { + let api_key = if let Some(api_key) = self.api_key.borrow().clone() { + api_key + } else { + return; + }; + let inline_assist_id = post_inc(&mut self.next_inline_assist_id); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selection = editor.read(cx).selections.newest_anchor().clone(); let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot); + let provider = Arc::new(OpenAICompletionProvider::new( + api_key, + cx.background().clone(), + )); + let codegen = + cx.add_model(|cx| Codegen::new(editor.read(cx).buffer().clone(), range, provider, cx)); let assist_kind = if editor.read(cx).selections.newest::(cx).is_empty() { InlineAssistKind::Generate } else { @@ -283,6 +291,7 @@ impl AssistantPanel { measurements.clone(), self.include_conversation_in_next_inline_assist, self.inline_prompt_history.clone(), + codegen.clone(), cx, ); cx.focus_self(); @@ -323,46 +332,55 @@ impl AssistantPanel { PendingInlineAssist { kind: assist_kind, editor: editor.downgrade(), - range, - highlighted_ranges: Default::default(), inline_assistant: Some((block_id, inline_assistant.clone())), - code_generation: Task::ready(None), - transaction_id: None, + codegen: codegen.clone(), _subscriptions: vec![ cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event), cx.subscribe(editor, { let inline_assistant = inline_assistant.downgrade(); - move |this, editor, event, cx| { + move |_, editor, event, cx| { if let Some(inline_assistant) = inline_assistant.upgrade(cx) { - match event { - editor::Event::SelectionsChanged { local } => { - if *local && inline_assistant.read(cx).has_focus { - cx.focus(&editor); - } + if let editor::Event::SelectionsChanged { local } = event { + if *local && inline_assistant.read(cx).has_focus { + cx.focus(&editor); } - editor::Event::TransactionUndone { - transaction_id: tx_id, - } => { - if let Some(pending_assist) = - this.pending_inline_assists.get(&inline_assist_id) - { - if pending_assist.transaction_id == Some(*tx_id) { - // Notice we are supplying `undo: false` here. This - // is because there's no need to undo the transaction - // because the user just did so. - this.close_inline_assist( - inline_assist_id, - false, - cx, - ); - } - } - } - _ => {} } } } }), + cx.subscribe(&codegen, move |this, codegen, event, cx| match event { + codegen::Event::Undone => { + this.finish_inline_assist(inline_assist_id, false, cx) + } + codegen::Event::Finished => { + let pending_assist = if let Some(pending_assist) = + this.pending_inline_assists.get(&inline_assist_id) + { + pending_assist + } else { + return; + }; + + let error = codegen + .read(cx) + .error() + .map(|error| format!("Inline assistant error: {}", error)); + if let Some(error) = error { + if pending_assist.inline_assistant.is_none() { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(inline_assist_id, error), + cx, + ); + }) + } + } + } + + this.finish_inline_assist(inline_assist_id, false, cx); + } + }), ], }, ); @@ -388,7 +406,7 @@ impl AssistantPanel { self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx); } InlineAssistantEvent::Canceled => { - self.close_inline_assist(assist_id, true, cx); + self.finish_inline_assist(assist_id, true, cx); } InlineAssistantEvent::Dismissed => { self.hide_inline_assist(assist_id, cx); @@ -417,7 +435,7 @@ impl AssistantPanel { .get(&editor.downgrade()) .and_then(|assist_ids| assist_ids.last().copied()) { - panel.close_inline_assist(assist_id, true, cx); + panel.finish_inline_assist(assist_id, true, cx); true } else { false @@ -432,7 +450,7 @@ impl AssistantPanel { cx.propagate_action(); } - fn close_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext) { + fn finish_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext) { self.hide_inline_assist(assist_id, cx); if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { @@ -450,13 +468,9 @@ impl AssistantPanel { self.update_highlights_for_editor(&editor, cx); if undo { - if let Some(transaction_id) = pending_assist.transaction_id { - editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - buffer.undo_transaction(transaction_id, cx) - }); - }); - } + pending_assist + .codegen + .update(cx, |codegen, cx| codegen.undo(cx)); } } } @@ -481,12 +495,6 @@ impl AssistantPanel { include_conversation: bool, cx: &mut ViewContext, ) { - let api_key = if let Some(api_key) = self.api_key.borrow().clone() { - api_key - } else { - return; - }; - let conversation = if include_conversation { self.active_editor() .map(|editor| editor.read(cx).conversation.clone()) @@ -514,56 +522,9 @@ impl AssistantPanel { self.inline_prompt_history.pop_front(); } - let range = pending_assist.range.clone(); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let selected_text = snapshot - .text_for_range(range.start..range.end) - .collect::(); - - let selection_start = range.start.to_point(&snapshot); - let selection_end = range.end.to_point(&snapshot); - - let mut base_indent: Option = None; - let mut start_row = selection_start.row; - if snapshot.is_line_blank(start_row) { - if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { - start_row = prev_non_blank_row; - } - } - for row in start_row..=selection_end.row { - if snapshot.is_line_blank(row) { - continue; - } - - let line_indent = snapshot.indent_size_for_line(row); - if let Some(base_indent) = base_indent.as_mut() { - if line_indent.len < base_indent.len { - *base_indent = line_indent; - } - } else { - base_indent = Some(line_indent); - } - } - - let mut normalized_selected_text = selected_text.clone(); - if let Some(base_indent) = base_indent { - for row in selection_start.row..=selection_end.row { - let selection_row = row - selection_start.row; - let line_start = - normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); - let indent_len = if row == selection_start.row { - base_indent.len.saturating_sub(selection_start.column) - } else { - let line_len = normalized_selected_text.line_len(selection_row); - cmp::min(line_len, base_indent.len) - }; - let indent_end = cmp::min( - line_start + indent_len as usize, - normalized_selected_text.len(), - ); - normalized_selected_text.replace(line_start..indent_end, ""); - } - } + let range = pending_assist.codegen.read(cx).range(); + let selected_text = snapshot.text_for_range(range.clone()).collect::(); let language = snapshot.language_at(range.start); let language_name = if let Some(language) = language.as_ref() { @@ -608,7 +569,7 @@ impl AssistantPanel { } else { writeln!(prompt, "```").unwrap(); } - writeln!(prompt, "{normalized_selected_text}").unwrap(); + writeln!(prompt, "{selected_text}").unwrap(); writeln!(prompt, "```").unwrap(); writeln!(prompt).unwrap(); writeln!( @@ -689,209 +650,9 @@ impl AssistantPanel { messages, stream: true, }; - let response = stream_completion(api_key, cx.background().clone(), request); - let editor = editor.downgrade(); - - pending_assist.code_generation = cx.spawn(|this, mut cx| { - async move { - let mut edit_start = range.start.to_offset(&snapshot); - - let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); - let diff = cx.background().spawn(async move { - let chunks = strip_markdown_codeblock(response.await?.filter_map( - |message| async move { - match message { - Ok(mut message) => Some(Ok(message.choices.pop()?.delta.content?)), - Err(error) => Some(Err(error)), - } - }, - )); - futures::pin_mut!(chunks); - let mut diff = StreamingDiff::new(selected_text.to_string()); - - let mut indent_len; - let indent_text; - if let Some(base_indent) = base_indent { - indent_len = base_indent.len; - indent_text = match base_indent.kind { - language::IndentKind::Space => " ", - language::IndentKind::Tab => "\t", - }; - } else { - indent_len = 0; - indent_text = ""; - }; - - let mut first_line_len = 0; - let mut first_line_non_whitespace_char_ix = None; - let mut first_line = true; - let mut new_text = String::new(); - - while let Some(chunk) = chunks.next().await { - let chunk = chunk?; - - let mut lines = chunk.split('\n'); - if let Some(mut line) = lines.next() { - if first_line { - if first_line_non_whitespace_char_ix.is_none() { - if let Some(mut char_ix) = - line.find(|ch: char| !ch.is_whitespace()) - { - line = &line[char_ix..]; - char_ix += first_line_len; - first_line_non_whitespace_char_ix = Some(char_ix); - let first_line_indent = char_ix - .saturating_sub(selection_start.column as usize) - as usize; - new_text.push_str(&indent_text.repeat(first_line_indent)); - indent_len = indent_len.saturating_sub(char_ix as u32); - } - } - first_line_len += line.len(); - } - - if first_line_non_whitespace_char_ix.is_some() { - new_text.push_str(line); - } - } - - for line in lines { - first_line = false; - new_text.push('\n'); - if !line.is_empty() { - new_text.push_str(&indent_text.repeat(indent_len as usize)); - } - new_text.push_str(line); - } - - let hunks = diff.push_new(&new_text); - hunks_tx.send(hunks).await?; - new_text.clear(); - } - hunks_tx.send(diff.finish()).await?; - - anyhow::Ok(()) - }); - - while let Some(hunks) = hunks_rx.next().await { - let editor = if let Some(editor) = editor.upgrade(&cx) { - editor - } else { - break; - }; - - let this = if let Some(this) = this.upgrade(&cx) { - this - } else { - break; - }; - - this.update(&mut cx, |this, cx| { - let pending_assist = if let Some(pending_assist) = - this.pending_inline_assists.get_mut(&inline_assist_id) - { - pending_assist - } else { - return; - }; - - pending_assist.highlighted_ranges.clear(); - editor.update(cx, |editor, cx| { - let transaction = editor.buffer().update(cx, |buffer, cx| { - // Avoid grouping assistant edits with user edits. - buffer.finalize_last_transaction(cx); - - buffer.start_transaction(cx); - buffer.edit( - hunks.into_iter().filter_map(|hunk| match hunk { - Hunk::Insert { text } => { - let edit_start = snapshot.anchor_after(edit_start); - Some((edit_start..edit_start, text)) - } - Hunk::Remove { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start = edit_end; - Some((edit_range, String::new())) - } - Hunk::Keep { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start += len; - pending_assist.highlighted_ranges.push(edit_range); - None - } - }), - None, - cx, - ); - - buffer.end_transaction(cx) - }); - - if let Some(transaction) = transaction { - if let Some(first_transaction) = pending_assist.transaction_id { - // Group all assistant edits into the first transaction. - editor.buffer().update(cx, |buffer, cx| { - buffer.merge_transactions( - transaction, - first_transaction, - cx, - ) - }); - } else { - pending_assist.transaction_id = Some(transaction); - editor.buffer().update(cx, |buffer, cx| { - buffer.finalize_last_transaction(cx) - }); - } - } - }); - - this.update_highlights_for_editor(&editor, cx); - }); - } - - if let Err(error) = diff.await { - this.update(&mut cx, |this, cx| { - let pending_assist = if let Some(pending_assist) = - this.pending_inline_assists.get_mut(&inline_assist_id) - { - pending_assist - } else { - return; - }; - - if let Some((_, inline_assistant)) = - pending_assist.inline_assistant.as_ref() - { - inline_assistant.update(cx, |inline_assistant, cx| { - inline_assistant.set_error(error, cx); - }); - } else if let Some(workspace) = this.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - inline_assist_id, - format!("Inline assistant error: {}", error), - ), - cx, - ); - }) - } - })?; - } else { - let _ = this.update(&mut cx, |this, cx| { - this.close_inline_assist(inline_assist_id, false, cx) - }); - } - - anyhow::Ok(()) - } - .log_err() - }); + pending_assist + .codegen + .update(cx, |codegen, cx| codegen.start(request, cx)); } fn update_highlights_for_editor( @@ -909,8 +670,9 @@ impl AssistantPanel { for inline_assist_id in inline_assist_ids { if let Some(pending_assist) = self.pending_inline_assists.get(inline_assist_id) { - background_ranges.push(pending_assist.range.clone()); - foreground_ranges.extend(pending_assist.highlighted_ranges.iter().cloned()); + let codegen = pending_assist.codegen.read(cx); + background_ranges.push(codegen.range()); + foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); } } @@ -2900,11 +2662,11 @@ struct InlineAssistant { has_focus: bool, include_conversation: bool, measurements: Rc>, - error: Option, prompt_history: VecDeque, prompt_history_ix: Option, pending_prompt: String, - _subscription: Subscription, + codegen: ModelHandle, + _subscriptions: Vec, } impl Entity for InlineAssistant { @@ -2933,7 +2695,7 @@ impl View for InlineAssistant { .element() .aligned(), ) - .with_children(if let Some(error) = self.error.as_ref() { + .with_children(if let Some(error) = self.codegen.read(cx).error() { Some( Svg::new("icons/circle_x_mark_12.svg") .with_color(theme.assistant.error_icon.color) @@ -3011,6 +2773,7 @@ impl InlineAssistant { measurements: Rc>, include_conversation: bool, prompt_history: VecDeque, + codegen: ModelHandle, cx: &mut ViewContext, ) -> Self { let prompt_editor = cx.add_view(|cx| { @@ -3025,7 +2788,10 @@ impl InlineAssistant { editor.set_placeholder_text(placeholder, cx); editor }); - let subscription = cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events); + let subscriptions = vec![ + cx.observe(&codegen, Self::handle_codegen_changed), + cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events), + ]; Self { id, prompt_editor, @@ -3033,11 +2799,11 @@ impl InlineAssistant { has_focus: false, include_conversation, measurements, - error: None, prompt_history, prompt_history_ix: None, pending_prompt: String::new(), - _subscription: subscription, + codegen, + _subscriptions: subscriptions, } } @@ -3053,6 +2819,31 @@ impl InlineAssistant { } } + fn handle_codegen_changed(&mut self, _: ModelHandle, cx: &mut ViewContext) { + let is_read_only = !self.codegen.read(cx).idle(); + self.prompt_editor.update(cx, |editor, cx| { + let was_read_only = editor.read_only(); + if was_read_only != is_read_only { + if is_read_only { + editor.set_read_only(true); + editor.set_field_editor_style( + Some(Arc::new(|theme| { + theme.assistant.inline.disabled_editor.clone() + })), + cx, + ); + } else { + editor.set_read_only(false); + editor.set_field_editor_style( + Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), + cx, + ); + } + } + }); + cx.notify(); + } + fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { cx.emit(InlineAssistantEvent::Canceled); } @@ -3076,7 +2867,6 @@ impl InlineAssistant { include_conversation: self.include_conversation, }); self.confirmed = true; - self.error = None; cx.notify(); } } @@ -3093,19 +2883,6 @@ impl InlineAssistant { cx.notify(); } - fn set_error(&mut self, error: anyhow::Error, cx: &mut ViewContext) { - self.error = Some(error); - self.confirmed = false; - self.prompt_editor.update(cx, |editor, cx| { - editor.set_read_only(false); - editor.set_field_editor_style( - Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), - cx, - ); - }); - cx.notify(); - } - fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -3154,11 +2931,8 @@ struct BlockMeasurements { struct PendingInlineAssist { kind: InlineAssistKind, editor: WeakViewHandle, - range: Range, - highlighted_ranges: Vec>, inline_assistant: Option<(BlockId, ViewHandle)>, - code_generation: Task>, - transaction_id: Option, + codegen: ModelHandle, _subscriptions: Vec, } @@ -3184,65 +2958,10 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } -fn strip_markdown_codeblock( - stream: impl Stream>, -) -> impl Stream> { - let mut first_line = true; - let mut buffer = String::new(); - let mut starts_with_fenced_code_block = false; - stream.filter_map(move |chunk| { - let chunk = match chunk { - Ok(chunk) => chunk, - Err(err) => return future::ready(Some(Err(err))), - }; - buffer.push_str(&chunk); - - if first_line { - if buffer == "" || buffer == "`" || buffer == "``" { - return future::ready(None); - } else if buffer.starts_with("```") { - starts_with_fenced_code_block = true; - if let Some(newline_ix) = buffer.find('\n') { - buffer.replace_range(..newline_ix + 1, ""); - first_line = false; - } else { - return future::ready(None); - } - } - } - - let text = if starts_with_fenced_code_block { - buffer - .strip_suffix("\n```\n") - .or_else(|| buffer.strip_suffix("\n```")) - .or_else(|| buffer.strip_suffix("\n``")) - .or_else(|| buffer.strip_suffix("\n`")) - .or_else(|| buffer.strip_suffix('\n')) - .unwrap_or(&buffer) - } else { - &buffer - }; - - if text.contains('\n') { - first_line = false; - } - - let remainder = buffer.split_off(text.len()); - let result = if buffer.is_empty() { - None - } else { - Some(Ok(buffer.clone())) - }; - buffer = remainder; - future::ready(result) - }) -} - #[cfg(test)] mod tests { use super::*; use crate::MessageId; - use futures::stream; use gpui::AppContext; #[gpui::test] @@ -3611,62 +3330,6 @@ mod tests { ); } - #[gpui::test] - async fn test_strip_markdown_codeblock() { - assert_eq!( - strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "Lorem ipsum dolor" - ); - assert_eq!( - strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "```js\nLorem ipsum dolor\n```" - ); - assert_eq!( - strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) - .map(|chunk| chunk.unwrap()) - .collect::() - .await, - "``\nLorem ipsum dolor\n```" - ); - - fn chunks(text: &str, size: usize) -> impl Stream> { - stream::iter( - text.chars() - .collect::>() - .chunks(size) - .map(|chunk| Ok(chunk.iter().collect::())) - .collect::>(), - ) - } - } - fn messages( conversation: &ModelHandle, cx: &AppContext, diff --git a/crates/ai/src/codegen.rs b/crates/ai/src/codegen.rs new file mode 100644 index 0000000000..b24c0f9435 --- /dev/null +++ b/crates/ai/src/codegen.rs @@ -0,0 +1,468 @@ +use crate::{ + stream_completion, + streaming_diff::{Hunk, StreamingDiff}, + OpenAIRequest, +}; +use anyhow::Result; +use editor::{multi_buffer, Anchor, MultiBuffer, ToOffset, ToPoint}; +use futures::{ + channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, SinkExt, Stream, StreamExt, +}; +use gpui::{executor::Background, Entity, ModelContext, ModelHandle, Task}; +use language::{IndentSize, Point, Rope, TransactionId}; +use std::{cmp, future, ops::Range, sync::Arc}; + +pub trait CompletionProvider { + fn complete( + &self, + prompt: OpenAIRequest, + ) -> BoxFuture<'static, Result>>>; +} + +pub struct OpenAICompletionProvider { + api_key: String, + executor: Arc, +} + +impl OpenAICompletionProvider { + pub fn new(api_key: String, executor: Arc) -> Self { + Self { api_key, executor } + } +} + +impl CompletionProvider for OpenAICompletionProvider { + fn complete( + &self, + prompt: OpenAIRequest, + ) -> BoxFuture<'static, Result>>> { + let request = stream_completion(self.api_key.clone(), self.executor.clone(), prompt); + async move { + let response = request.await?; + let stream = response + .filter_map(|response| async move { + match response { + Ok(mut response) => Some(Ok(response.choices.pop()?.delta.content?)), + Err(error) => Some(Err(error)), + } + }) + .boxed(); + Ok(stream) + } + .boxed() + } +} + +pub enum Event { + Finished, + Undone, +} + +pub struct Codegen { + provider: Arc, + buffer: ModelHandle, + range: Range, + last_equal_ranges: Vec>, + transaction_id: Option, + error: Option, + generation: Task<()>, + idle: bool, + _subscription: gpui::Subscription, +} + +impl Entity for Codegen { + type Event = Event; +} + +impl Codegen { + pub fn new( + buffer: ModelHandle, + range: Range, + provider: Arc, + cx: &mut ModelContext, + ) -> Self { + Self { + provider, + buffer: buffer.clone(), + range, + last_equal_ranges: Default::default(), + transaction_id: Default::default(), + error: Default::default(), + idle: true, + generation: Task::ready(()), + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), + } + } + + fn handle_buffer_event( + &mut self, + _buffer: ModelHandle, + event: &multi_buffer::Event, + cx: &mut ModelContext, + ) { + if let multi_buffer::Event::TransactionUndone { transaction_id } = event { + if self.transaction_id == Some(*transaction_id) { + self.transaction_id = None; + self.generation = Task::ready(()); + cx.emit(Event::Undone); + } + } + } + + pub fn range(&self) -> Range { + self.range.clone() + } + + pub fn last_equal_ranges(&self) -> &[Range] { + &self.last_equal_ranges + } + + pub fn idle(&self) -> bool { + self.idle + } + + pub fn error(&self) -> Option<&anyhow::Error> { + self.error.as_ref() + } + + pub fn start(&mut self, prompt: OpenAIRequest, cx: &mut ModelContext) { + let range = self.range.clone(); + let snapshot = self.buffer.read(cx).snapshot(cx); + let selected_text = snapshot + .text_for_range(range.start..range.end) + .collect::(); + + let selection_start = range.start.to_point(&snapshot); + let selection_end = range.end.to_point(&snapshot); + + let mut base_indent: Option = None; + let mut start_row = selection_start.row; + if snapshot.is_line_blank(start_row) { + if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { + start_row = prev_non_blank_row; + } + } + for row in start_row..=selection_end.row { + if snapshot.is_line_blank(row) { + continue; + } + + let line_indent = snapshot.indent_size_for_line(row); + if let Some(base_indent) = base_indent.as_mut() { + if line_indent.len < base_indent.len { + *base_indent = line_indent; + } + } else { + base_indent = Some(line_indent); + } + } + + let mut normalized_selected_text = selected_text.clone(); + if let Some(base_indent) = base_indent { + for row in selection_start.row..=selection_end.row { + let selection_row = row - selection_start.row; + let line_start = + normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); + let indent_len = if row == selection_start.row { + base_indent.len.saturating_sub(selection_start.column) + } else { + let line_len = normalized_selected_text.line_len(selection_row); + cmp::min(line_len, base_indent.len) + }; + let indent_end = cmp::min( + line_start + indent_len as usize, + normalized_selected_text.len(), + ); + normalized_selected_text.replace(line_start..indent_end, ""); + } + } + + let response = self.provider.complete(prompt); + self.generation = cx.spawn_weak(|this, mut cx| { + async move { + let generate = async { + let mut edit_start = range.start.to_offset(&snapshot); + + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + let diff = cx.background().spawn(async move { + let chunks = strip_markdown_codeblock(response.await?); + futures::pin_mut!(chunks); + let mut diff = StreamingDiff::new(selected_text.to_string()); + + let mut indent_len; + let indent_text; + if let Some(base_indent) = base_indent { + indent_len = base_indent.len; + indent_text = match base_indent.kind { + language::IndentKind::Space => " ", + language::IndentKind::Tab => "\t", + }; + } else { + indent_len = 0; + indent_text = ""; + }; + + let mut first_line_len = 0; + let mut first_line_non_whitespace_char_ix = None; + let mut first_line = true; + let mut new_text = String::new(); + + while let Some(chunk) = chunks.next().await { + let chunk = chunk?; + + let mut lines = chunk.split('\n'); + if let Some(mut line) = lines.next() { + if first_line { + if first_line_non_whitespace_char_ix.is_none() { + if let Some(mut char_ix) = + line.find(|ch: char| !ch.is_whitespace()) + { + line = &line[char_ix..]; + char_ix += first_line_len; + first_line_non_whitespace_char_ix = Some(char_ix); + let first_line_indent = char_ix + .saturating_sub(selection_start.column as usize) + as usize; + new_text + .push_str(&indent_text.repeat(first_line_indent)); + indent_len = indent_len.saturating_sub(char_ix as u32); + } + } + first_line_len += line.len(); + } + + if first_line_non_whitespace_char_ix.is_some() { + new_text.push_str(line); + } + } + + for line in lines { + first_line = false; + new_text.push('\n'); + if !line.is_empty() { + new_text.push_str(&indent_text.repeat(indent_len as usize)); + } + new_text.push_str(line); + } + + let hunks = diff.push_new(&new_text); + hunks_tx.send(hunks).await?; + new_text.clear(); + } + hunks_tx.send(diff.finish()).await?; + + anyhow::Ok(()) + }); + + while let Some(hunks) = hunks_rx.next().await { + let this = if let Some(this) = this.upgrade(&cx) { + this + } else { + break; + }; + + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + + let transaction = this.buffer.update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. + buffer.finalize_last_transaction(cx); + + buffer.start_transaction(cx); + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { + Hunk::Insert { text } => { + let edit_start = snapshot.anchor_after(edit_start); + Some((edit_start..edit_start, text)) + } + Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + Some((edit_range, String::new())) + } + Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start += len; + this.last_equal_ranges.push(edit_range); + None + } + }), + None, + cx, + ); + + buffer.end_transaction(cx) + }); + + if let Some(transaction) = transaction { + if let Some(first_transaction) = this.transaction_id { + // Group all assistant edits into the first transaction. + this.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions( + transaction, + first_transaction, + cx, + ) + }); + } else { + this.transaction_id = Some(transaction); + this.buffer.update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx) + }); + } + } + + cx.notify(); + }); + } + + diff.await?; + anyhow::Ok(()) + }; + + let result = generate.await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + this.idle = true; + if let Err(error) = result { + this.error = Some(error); + } + cx.emit(Event::Finished); + cx.notify(); + }); + } + } + }); + self.error.take(); + self.idle = false; + cx.notify(); + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction_id) = self.transaction_id { + self.buffer + .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } + } +} + +fn strip_markdown_codeblock( + stream: impl Stream>, +) -> impl Stream> { + let mut first_line = true; + let mut buffer = String::new(); + let mut starts_with_fenced_code_block = false; + stream.filter_map(move |chunk| { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(err) => return future::ready(Some(Err(err))), + }; + buffer.push_str(&chunk); + + if first_line { + if buffer == "" || buffer == "`" || buffer == "``" { + return future::ready(None); + } else if buffer.starts_with("```") { + starts_with_fenced_code_block = true; + if let Some(newline_ix) = buffer.find('\n') { + buffer.replace_range(..newline_ix + 1, ""); + first_line = false; + } else { + return future::ready(None); + } + } + } + + let text = if starts_with_fenced_code_block { + buffer + .strip_suffix("\n```\n") + .or_else(|| buffer.strip_suffix("\n```")) + .or_else(|| buffer.strip_suffix("\n``")) + .or_else(|| buffer.strip_suffix("\n`")) + .or_else(|| buffer.strip_suffix('\n')) + .unwrap_or(&buffer) + } else { + &buffer + }; + + if text.contains('\n') { + first_line = false; + } + + let remainder = buffer.split_off(text.len()); + let result = if buffer.is_empty() { + None + } else { + Some(Ok(buffer.clone())) + }; + buffer = remainder; + future::ready(result) + }) +} + +#[cfg(test)] +mod tests { + use futures::stream; + + use super::*; + + #[gpui::test] + async fn test_strip_markdown_codeblock() { + assert_eq!( + strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "```js\nLorem ipsum dolor\n```" + ); + assert_eq!( + strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "``\nLorem ipsum dolor\n```" + ); + + fn chunks(text: &str, size: usize) -> impl Stream> { + stream::iter( + text.chars() + .collect::>() + .chunks(size) + .map(|chunk| Ok(chunk.iter().collect::())) + .collect::>(), + ) + } + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bdd29b04fa..12df29df1d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1734,6 +1734,10 @@ impl Editor { } } + pub fn read_only(&self) -> bool { + self.read_only + } + pub fn set_read_only(&mut self, read_only: bool) { self.read_only = read_only; } @@ -5103,9 +5107,6 @@ impl Editor { self.unmark_text(cx); self.refresh_copilot_suggestions(true, cx); cx.emit(Event::Edited); - cx.emit(Event::TransactionUndone { - transaction_id: tx_id, - }); } } @@ -8548,9 +8549,6 @@ pub enum Event { local: bool, autoscroll: bool, }, - TransactionUndone { - transaction_id: TransactionId, - }, Closed, } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 74283fd778..c5d17dfd2e 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -70,6 +70,9 @@ pub enum Event { Edited { sigleton_buffer_edited: bool, }, + TransactionUndone { + transaction_id: TransactionId, + }, Reloaded, DiffBaseChanged, LanguageChanged, @@ -771,30 +774,36 @@ impl MultiBuffer { } pub fn undo(&mut self, cx: &mut ModelContext) -> Option { + let mut transaction_id = None; if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, cx| buffer.undo(cx)); - } + transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx)); + } else { + while let Some(transaction) = self.history.pop_undo() { + let mut undone = false; + for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { + undone |= buffer.update(cx, |buffer, cx| { + let undo_to = *buffer_transaction_id; + if let Some(entry) = buffer.peek_undo_stack() { + *buffer_transaction_id = entry.transaction_id(); + } + buffer.undo_to_transaction(undo_to, cx) + }); + } + } - while let Some(transaction) = self.history.pop_undo() { - let mut undone = false; - for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { - undone |= buffer.update(cx, |buffer, cx| { - let undo_to = *buffer_transaction_id; - if let Some(entry) = buffer.peek_undo_stack() { - *buffer_transaction_id = entry.transaction_id(); - } - buffer.undo_to_transaction(undo_to, cx) - }); + if undone { + transaction_id = Some(transaction.id); + break; } } - - if undone { - return Some(transaction.id); - } } - None + if let Some(transaction_id) = transaction_id { + cx.emit(Event::TransactionUndone { transaction_id }); + } + + transaction_id } pub fn redo(&mut self, cx: &mut ModelContext) -> Option { From 6d9333dc3b46f668cee18f03c47e6a64b8224e8a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Sep 2023 14:35:15 +0200 Subject: [PATCH 02/28] Add a failing test for codegen autoindent --- Cargo.lock | 1 + crates/ai/Cargo.toml | 1 + crates/ai/src/ai.rs | 2 +- crates/ai/src/codegen.rs | 115 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 121e9a28dd..bf0ed9b163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "log", "menu", "ordered-float", + "parking_lot 0.11.2", "project", "rand 0.8.5", "regex", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 4438f88108..d96e470d5c 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -27,6 +27,7 @@ futures.workspace = true indoc.workspace = true isahc.workspace = true ordered-float.workspace = true +parking_lot.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 2e8eca80e3..7d9b93b0a7 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -27,7 +27,7 @@ use util::paths::CONVERSATIONS_DIR; const OPENAI_API_URL: &'static str = "https://api.openai.com/v1"; // Data types for chat completion requests -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize)] pub struct OpenAIRequest { model: String, messages: Vec, diff --git a/crates/ai/src/codegen.rs b/crates/ai/src/codegen.rs index b24c0f9435..9657d9a492 100644 --- a/crates/ai/src/codegen.rs +++ b/crates/ai/src/codegen.rs @@ -406,9 +406,68 @@ fn strip_markdown_codeblock( #[cfg(test)] mod tests { - use futures::stream; - use super::*; + use futures::stream; + use gpui::{executor::Deterministic, TestAppContext}; + use indoc::indoc; + use language::{tree_sitter_rust, Buffer, Language, LanguageConfig}; + use parking_lot::Mutex; + use rand::prelude::*; + + #[gpui::test(iterations = 10)] + async fn test_autoindent( + cx: &mut TestAppContext, + mut rng: StdRng, + deterministic: Arc, + ) { + let text = indoc! {" + fn main() { + let x = 0; + for _ in 0..10 { + x += 1; + } + } + "}; + let buffer = + cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 4))..snapshot.anchor_after(Point::new(4, 4)) + }); + let provider = Arc::new(TestCompletionProvider::new()); + let codegen = cx.add_model(|cx| Codegen::new(buffer.clone(), range, provider.clone(), cx)); + codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx)); + + let mut new_text = indoc! {" + let mut x = 0; + while x < 10 { + x += 1; + } + "}; + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + provider.send_completion(chunk); + new_text = suffix; + deterministic.run_until_parked(); + } + provider.finish_completion(); + deterministic.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } #[gpui::test] async fn test_strip_markdown_codeblock() { @@ -465,4 +524,56 @@ mod tests { ) } } + + struct TestCompletionProvider { + last_completion_tx: Mutex>>, + } + + impl TestCompletionProvider { + fn new() -> Self { + Self { + last_completion_tx: Mutex::new(None), + } + } + + fn send_completion(&self, completion: impl Into) { + let mut tx = self.last_completion_tx.lock(); + tx.as_mut().unwrap().try_send(completion.into()).unwrap(); + } + + fn finish_completion(&self) { + self.last_completion_tx.lock().take().unwrap(); + } + } + + impl CompletionProvider for TestCompletionProvider { + fn complete( + &self, + _prompt: OpenAIRequest, + ) -> BoxFuture<'static, Result>>> { + let (tx, rx) = mpsc::channel(1); + *self.last_completion_tx.lock() = Some(tx); + async move { Ok(rx.map(|rx| Ok(rx)).boxed()) }.boxed() + } + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_indents_query( + r#" + (call_expression) @indent + (field_expression) @indent + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + } } From e8a6ecd6ac5d4cb1657d2b373b81e343a0f51a0f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Sep 2023 13:07:11 -0600 Subject: [PATCH 03/28] Allow a count with CurrentLine Add _ and g_ too while we're here. --- assets/keymaps/vim.json | 2 ++ crates/vim/src/motion.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index b47907783e..62b6a49b90 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -32,6 +32,8 @@ "right": "vim::Right", "$": "vim::EndOfLine", "^": "vim::FirstNonWhitespace", + "_": "vim::StartOfLineDownward", + "g _": "vim::EndOfLineDownward", "shift-g": "vim::EndOfDocument", "w": "vim::NextWordStart", "{": "vim::StartOfParagraph", diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index c232ff9849..5179b56b1f 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -40,6 +40,8 @@ pub enum Motion { FindForward { before: bool, char: char }, FindBackward { after: bool, char: char }, NextLineStart, + StartOfLineDownward, + EndOfLineDownward, } #[derive(Clone, Deserialize, PartialEq)] @@ -117,6 +119,8 @@ actions!( EndOfDocument, Matching, NextLineStart, + StartOfLineDownward, + EndOfLineDownward, ] ); impl_actions!( @@ -207,6 +211,12 @@ pub fn init(cx: &mut AppContext) { cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) }, ); cx.add_action(|_: &mut Workspace, &NextLineStart, cx: _| motion(Motion::NextLineStart, cx)); + cx.add_action(|_: &mut Workspace, &StartOfLineDownward, cx: _| { + motion(Motion::StartOfLineDownward, cx) + }); + cx.add_action(|_: &mut Workspace, &EndOfLineDownward, cx: _| { + motion(Motion::EndOfLineDownward, cx) + }); cx.add_action(|_: &mut Workspace, action: &RepeatFind, cx: _| { repeat_motion(action.backwards, cx) }) @@ -272,6 +282,7 @@ impl Motion { | EndOfDocument | CurrentLine | NextLineStart + | StartOfLineDownward | StartOfParagraph | EndOfParagraph => true, EndOfLine { .. } @@ -282,6 +293,7 @@ impl Motion { | Backspace | Right | StartOfLine { .. } + | EndOfLineDownward | NextWordStart { .. } | PreviousWordStart { .. } | FirstNonWhitespace { .. } @@ -305,6 +317,8 @@ impl Motion { | StartOfLine { .. } | StartOfParagraph | EndOfParagraph + | StartOfLineDownward + | EndOfLineDownward | NextWordStart { .. } | PreviousWordStart { .. } | FirstNonWhitespace { .. } @@ -322,6 +336,7 @@ impl Motion { | EndOfDocument | CurrentLine | EndOfLine { .. } + | EndOfLineDownward | NextWordEnd { .. } | Matching | FindForward { .. } @@ -330,6 +345,7 @@ impl Motion { | Backspace | Right | StartOfLine { .. } + | StartOfLineDownward | StartOfParagraph | EndOfParagraph | NextWordStart { .. } @@ -396,7 +412,7 @@ impl Motion { map.clip_at_line_end(movement::end_of_paragraph(map, point, times)), SelectionGoal::None, ), - CurrentLine => (end_of_line(map, false, point), SelectionGoal::None), + CurrentLine => (next_line_end(map, point, 1), SelectionGoal::None), StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None), EndOfDocument => ( end_of_document(map, point, maybe_times), @@ -412,6 +428,8 @@ impl Motion { SelectionGoal::None, ), NextLineStart => (next_line_start(map, point, times), SelectionGoal::None), + StartOfLineDownward => (next_line_start(map, point, times - 1), SelectionGoal::None), + EndOfLineDownward => (next_line_end(map, point, times), SelectionGoal::None), }; (new_point != point || infallible).then_some((new_point, goal)) @@ -849,6 +867,13 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> first_non_whitespace(map, false, correct_line) } +fn next_line_end(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + if times > 1 { + point = down(map, point, SelectionGoal::None, times - 1).0; + } + end_of_line(map, false, point) +} + #[cfg(test)] mod test { From cee549e1ef2703b4ef8a9242e75ff108c92de497 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Sep 2023 13:10:01 -0600 Subject: [PATCH 04/28] vim: Fix count handling to allow pre/post counts Fixes 2yy, d3d, etc. For zed-industries/community#970 For zed-industries/community#1496 --- assets/keymaps/vim.json | 4 +- crates/vim/src/motion.rs | 8 +-- crates/vim/src/normal.rs | 10 +-- crates/vim/src/normal/case.rs | 2 +- crates/vim/src/normal/delete.rs | 36 ++++++++++ crates/vim/src/normal/repeat.rs | 40 ++++++++++- crates/vim/src/normal/scroll.rs | 2 +- crates/vim/src/normal/search.rs | 4 +- crates/vim/src/normal/substitute.rs | 4 +- crates/vim/src/state.rs | 20 +++++- crates/vim/src/vim.rs | 70 ++++++++++--------- .../test_data/test_delete_with_counts.json | 16 +++++ .../test_data/test_repeat_motion_counts.json | 13 ++++ 13 files changed, 175 insertions(+), 54 deletions(-) create mode 100644 crates/vim/test_data/test_delete_with_counts.json create mode 100644 crates/vim/test_data/test_repeat_motion_counts.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 62b6a49b90..bbc0a51b28 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -328,7 +328,7 @@ } }, { - "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting", + "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", "bindings": { ".": "vim::Repeat", "c": [ @@ -391,7 +391,7 @@ } }, { - "context": "Editor && vim_operator == n", + "context": "Editor && VimCount", "bindings": { "0": [ "vim::Number", diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 5179b56b1f..6821ec64e5 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -229,11 +229,11 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| vim.pop_operator(cx)); } - let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx)); + let count = Vim::update(cx, |vim, _| vim.take_count()); let operator = Vim::read(cx).active_operator(); match Vim::read(cx).state().mode { - Mode::Normal => normal_motion(motion, operator, times, cx), - Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, times, cx), + Mode::Normal => normal_motion(motion, operator, count, cx), + Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, count, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } @@ -412,7 +412,7 @@ impl Motion { map.clip_at_line_end(movement::end_of_paragraph(map, point, times)), SelectionGoal::None, ), - CurrentLine => (next_line_end(map, point, 1), SelectionGoal::None), + CurrentLine => (next_line_end(map, point, times), SelectionGoal::None), StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None), EndOfDocument => ( end_of_document(map, point, maybe_times), diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index d920abee90..3ef3f9ddd3 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -68,21 +68,21 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.pop_number_operator(cx); + let times = vim.take_count(); delete_motion(vim, Motion::Left, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.pop_number_operator(cx); + let times = vim.take_count(); delete_motion(vim, Motion::Right, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { Vim::update(cx, |vim, cx| { vim.start_recording(cx); - let times = vim.pop_number_operator(cx); + let times = vim.take_count(); change_motion( vim, Motion::EndOfLine { @@ -96,7 +96,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.pop_number_operator(cx); + let times = vim.take_count(); delete_motion( vim, Motion::EndOfLine { @@ -110,7 +110,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let mut times = vim.pop_number_operator(cx).unwrap_or(1); + let mut times = vim.take_count().unwrap_or(1); if vim.state().mode.is_visual() { times = 1; } else if times > 1 { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 12fd8dbd2b..34b81fbb4c 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -8,7 +8,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; + let count = vim.take_count().unwrap_or(1) as u32; vim.update_active_editor(cx, |editor, cx| { let mut ranges = Vec::new(); let mut cursor_positions = Vec::new(); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index ae85acaab5..1126eb1155 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -387,4 +387,40 @@ mod test { assert_eq!(cx.active_operator(), None); assert_eq!(cx.mode(), Mode::Normal); } + + #[gpui::test] + async fn test_delete_with_counts(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! {" + The ˇquick brown + fox jumps over + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["d", "2", "d"]).await; + cx.assert_shared_state(indoc! {" + the ˇlazy dog"}) + .await; + + cx.set_shared_state(indoc! {" + The ˇquick brown + fox jumps over + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["2", "d", "d"]).await; + cx.assert_shared_state(indoc! {" + the ˇlazy dog"}) + .await; + + cx.set_shared_state(indoc! {" + The ˇquick brown + fox jumps over + the moon, + a star, and + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["2", "d", "2", "d"]).await; + cx.assert_shared_state(indoc! {" + the ˇlazy dog"}) + .await; + } } diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 1a7c789aad..28f9e3c2a4 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -34,7 +34,7 @@ pub(crate) fn init(cx: &mut AppContext) { let Some(editor) = vim.active_editor.clone() else { return None; }; - let count = vim.pop_number_operator(cx); + let count = vim.take_count(); vim.workspace_state.replaying = true; @@ -424,4 +424,42 @@ mod test { }) .await; } + + #[gpui::test] + async fn test_repeat_motion_counts( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["3", "d", "3", "l"]).await; + cx.assert_shared_state(indoc! { + "ˇ brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + " brown + ˇ over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "2", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + " brown + over + ˇe lazy dog" + }) + .await; + } } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 1b3dcee6ad..6319ba1663 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -48,7 +48,7 @@ pub fn init(cx: &mut AppContext) { fn scroll(cx: &mut ViewContext, by: fn(c: Option) -> ScrollAmount) { Vim::update(cx, |vim, cx| { - let amount = by(vim.pop_number_operator(cx).map(|c| c as f32)); + let amount = by(vim.take_count().map(|c| c as f32)); vim.update_active_editor(cx, |editor, cx| scroll_editor(editor, &amount, cx)); }) } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 4ca0c42909..b488a879f2 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -52,7 +52,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext() { search_bar.update(cx, |search_bar, cx| { @@ -119,7 +119,7 @@ pub fn move_to_internal( ) { Vim::update(cx, |vim, cx| { let pane = workspace.active_pane().clone(); - let count = vim.pop_number_operator(cx).unwrap_or(1); + let count = vim.take_count().unwrap_or(1); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { let search = search_bar.update(cx, |search_bar, cx| { diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index d0dbb9e306..26aff7baa4 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -11,7 +11,7 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { Vim::update(cx, |vim, cx| { vim.start_recording(cx); - let count = vim.pop_number_operator(cx); + let count = vim.take_count(); substitute(vim, count, vim.state().mode == Mode::VisualLine, cx); }) }); @@ -22,7 +22,7 @@ pub(crate) fn init(cx: &mut AppContext) { if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } - let count = vim.pop_number_operator(cx); + let count = vim.take_count(); substitute(vim, count, true, cx) }) }); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 7359178f0e..8fd4049767 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -33,7 +33,6 @@ impl Default for Mode { #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Operator { - Number(usize), Change, Delete, Yank, @@ -47,6 +46,12 @@ pub enum Operator { pub struct EditorState { pub mode: Mode, pub last_mode: Mode, + + /// pre_count is the number before an operator is specified (3 in 3d2d) + pub pre_count: Option, + /// post_count is the number after an operator is specified (2 in 3d2d) + pub post_count: Option, + pub operator_stack: Vec, } @@ -158,6 +163,10 @@ impl EditorState { } } + pub fn active_operator(&self) -> Option { + self.operator_stack.last().copied() + } + pub fn keymap_context_layer(&self) -> KeymapContext { let mut context = KeymapContext::default(); context.add_identifier("VimEnabled"); @@ -174,7 +183,13 @@ impl EditorState { context.add_identifier("VimControl"); } - let active_operator = self.operator_stack.last(); + if self.active_operator().is_none() && self.pre_count.is_some() + || self.active_operator().is_some() && self.post_count.is_some() + { + context.add_identifier("VimCount"); + } + + let active_operator = self.active_operator(); if let Some(active_operator) = active_operator { for context_flag in active_operator.context_flags().into_iter() { @@ -194,7 +209,6 @@ impl EditorState { impl Operator { pub fn id(&self) -> &'static str { match self { - Operator::Number(_) => "n", Operator::Object { around: false } => "i", Operator::Object { around: true } => "a", Operator::Change => "c", diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 03a74d46ce..74363bc7b7 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -40,9 +40,12 @@ pub struct SwitchMode(pub Mode); pub struct PushOperator(pub Operator); #[derive(Clone, Deserialize, PartialEq)] -struct Number(u8); +struct Number(usize); -actions!(vim, [Tab, Enter]); +actions!( + vim, + [Tab, Enter, Object, InnerObject, FindForward, FindBackward] +); impl_actions!(vim, [Number, SwitchMode, PushOperator]); #[derive(Copy, Clone, Debug)] @@ -70,7 +73,7 @@ pub fn init(cx: &mut AppContext) { }, ); cx.add_action(|_: &mut Workspace, n: &Number, cx: _| { - Vim::update(cx, |vim, cx| vim.push_number(n, cx)); + Vim::update(cx, |vim, _| vim.push_count_digit(n.0)); }); cx.add_action(|_: &mut Workspace, _: &Tab, cx| { @@ -236,12 +239,7 @@ impl Vim { if !self.workspace_state.replaying { self.workspace_state.recording = true; self.workspace_state.recorded_actions = Default::default(); - self.workspace_state.recorded_count = - if let Some(Operator::Number(number)) = self.active_operator() { - Some(number) - } else { - None - }; + self.workspace_state.recorded_count = None; let selections = self .active_editor @@ -352,6 +350,36 @@ impl Vim { }); } + fn push_count_digit(&mut self, number: usize) { + if self.active_operator().is_some() { + self.update_state(|state| { + state.post_count = Some(state.post_count.unwrap_or(0) * 10 + number) + }) + } else { + self.update_state(|state| { + state.pre_count = Some(state.pre_count.unwrap_or(0) * 10 + number) + }) + } + } + + fn take_count(&mut self) -> Option { + if self.workspace_state.replaying { + return self.workspace_state.recorded_count; + } + + let count = if self.state().post_count == None && self.state().pre_count == None { + return None; + } else { + Some(self.update_state(|state| { + state.post_count.take().unwrap_or(1) * state.pre_count.take().unwrap_or(1) + })) + }; + if self.workspace_state.recording { + self.workspace_state.recorded_count = count; + } + count + } + fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { if matches!( operator, @@ -363,15 +391,6 @@ impl Vim { self.sync_vim_settings(cx); } - fn push_number(&mut self, Number(number): &Number, cx: &mut WindowContext) { - if let Some(Operator::Number(current_number)) = self.active_operator() { - self.pop_operator(cx); - self.push_operator(Operator::Number(current_number * 10 + *number as usize), cx); - } else { - self.push_operator(Operator::Number(*number as usize), cx); - } - } - fn maybe_pop_operator(&mut self) -> Option { self.update_state(|state| state.operator_stack.pop()) } @@ -382,21 +401,6 @@ impl Vim { self.sync_vim_settings(cx); popped_operator } - - fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option { - if self.workspace_state.replaying { - if let Some(number) = self.workspace_state.recorded_count { - return Some(number); - } - } - - if let Some(Operator::Number(number)) = self.active_operator() { - self.pop_operator(cx); - return Some(number); - } - None - } - fn clear_operator(&mut self, cx: &mut WindowContext) { self.update_state(|state| state.operator_stack.clear()); self.sync_vim_settings(cx); diff --git a/crates/vim/test_data/test_delete_with_counts.json b/crates/vim/test_data/test_delete_with_counts.json new file mode 100644 index 0000000000..de19c5d29d --- /dev/null +++ b/crates/vim/test_data/test_delete_with_counts.json @@ -0,0 +1,16 @@ +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"d"} +{"Key":"2"} +{"Key":"d"} +{"Get":{"state":"the ˇlazy dog","mode":"Normal"}} +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"2"} +{"Key":"d"} +{"Key":"d"} +{"Get":{"state":"the ˇlazy dog","mode":"Normal"}} +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe moon,\na star, and\nthe lazy dog"}} +{"Key":"2"} +{"Key":"d"} +{"Key":"2"} +{"Key":"d"} +{"Get":{"state":"the ˇlazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_motion_counts.json b/crates/vim/test_data/test_repeat_motion_counts.json new file mode 100644 index 0000000000..c39b8b09c0 --- /dev/null +++ b/crates/vim/test_data/test_repeat_motion_counts.json @@ -0,0 +1,13 @@ +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"3"} +{"Key":"d"} +{"Key":"3"} +{"Key":"l"} +{"Get":{"state":"ˇ brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"."} +{"Get":{"state":" brown\nˇ over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"2"} +{"Key":"."} +{"Get":{"state":" brown\n over\nˇe lazy dog","mode":"Normal"}} From d868d00985ba29df4605c202f8e3d4f04572cda3 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Sep 2023 18:01:58 -0600 Subject: [PATCH 05/28] vim: ALlow counts on insert actions This re-uses the existing repeat infrastructure. --- assets/keymaps/default.json | 2 +- assets/keymaps/vim.json | 2 +- crates/vim/src/insert.rs | 119 +++++++- crates/vim/src/normal.rs | 2 +- crates/vim/src/normal/repeat.rs | 277 +++++++++++------- crates/vim/src/vim.rs | 14 +- .../test_data/test_insert_with_counts.json | 36 +++ .../test_data/test_insert_with_repeat.json | 23 ++ 8 files changed, 343 insertions(+), 132 deletions(-) create mode 100644 crates/vim/test_data/test_insert_with_counts.json create mode 100644 crates/vim/test_data/test_insert_with_repeat.json diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index fa62a74f3f..2fb1c6f5fc 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -533,7 +533,7 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", "cmd-alt-i": "zed::DebugElements", - "ctrl-:": "editor::ToggleInlayHints", + "ctrl-:": "editor::ToggleInlayHints" } }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index bbc0a51b28..1a7b81ee8f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -499,7 +499,7 @@ "around": true } } - ], + ] } }, { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 9141a02ab3..7495b302a2 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -1,6 +1,6 @@ -use crate::{state::Mode, Vim}; +use crate::{normal::repeat, state::Mode, Vim}; use editor::{scroll::autoscroll::Autoscroll, Bias}; -use gpui::{actions, AppContext, ViewContext}; +use gpui::{actions, Action, AppContext, ViewContext}; use language::SelectionGoal; use workspace::Workspace; @@ -10,24 +10,41 @@ pub fn init(cx: &mut AppContext) { cx.add_action(normal_before); } -fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| { - vim.stop_recording(); - vim.update_active_editor(cx, |editor, cx| { - editor.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_cursors_with(|map, mut cursor, _| { - *cursor.column_mut() = cursor.column().saturating_sub(1); - (map.clip_point(cursor, Bias::Left), SelectionGoal::None) +fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext) { + let should_repeat = Vim::update(cx, |vim, cx| { + let count = vim.take_count().unwrap_or(1); + vim.stop_recording_immediately(action.boxed_clone()); + if count <= 1 || vim.workspace_state.replaying { + vim.update_active_editor(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_cursors_with(|map, mut cursor, _| { + *cursor.column_mut() = cursor.column().saturating_sub(1); + (map.clip_point(cursor, Bias::Left), SelectionGoal::None) + }); }); }); - }); - vim.switch_mode(Mode::Normal, false, cx); - }) + vim.switch_mode(Mode::Normal, false, cx); + false + } else { + true + } + }); + + if should_repeat { + repeat::repeat(cx, true) + } } #[cfg(test)] mod test { - use crate::{state::Mode, test::VimTestContext}; + use std::sync::Arc; + + use gpui::executor::Deterministic; + + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; #[gpui::test] async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) { @@ -40,4 +57,78 @@ mod test { assert_eq!(cx.mode(), Mode::Normal); cx.assert_editor_state("Tesˇt"); } + + #[gpui::test] + async fn test_insert_with_counts( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["5", "i", "-", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("----ˇ-hello\n").await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["5", "a", "-", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("h----ˇ-ello\n").await; + + cx.simulate_shared_keystrokes(["4", "shift-i", "-", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("---ˇ-h-----ello\n").await; + + cx.simulate_shared_keystrokes(["3", "shift-a", "-", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("----h-----ello--ˇ-\n").await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["3", "o", "o", "i", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("hello\noi\noi\noˇi\n").await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["3", "shift-o", "o", "i", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("oi\noi\noˇi\nhello\n").await; + } + + #[gpui::test] + async fn test_insert_with_repeat( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["3", "i", "-", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("--ˇ-hello\n").await; + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("----ˇ--hello\n").await; + cx.simulate_shared_keystrokes(["2", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("-----ˇ---hello\n").await; + + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes(["2", "o", "k", "k", "escape"]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("hello\nkk\nkˇk\n").await; + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("hello\nkk\nkk\nkk\nkˇk\n").await; + cx.simulate_shared_keystrokes(["1", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("hello\nkk\nkk\nkk\nkk\nkˇk\n").await; + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 3ef3f9ddd3..16a4150dab 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,7 +2,7 @@ mod case; mod change; mod delete; mod paste; -mod repeat; +pub(crate) mod repeat; mod scroll; mod search; pub mod substitute; diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 28f9e3c2a4..6954ace71f 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -1,10 +1,11 @@ use crate::{ + insert::NormalBefore, motion::Motion, state::{Mode, RecordedSelection, ReplayableAction}, visual::visual_motion, Vim, }; -use gpui::{actions, Action, AppContext}; +use gpui::{actions, Action, AppContext, WindowContext}; use workspace::Workspace; actions!(vim, [Repeat, EndRepeat,]); @@ -17,6 +18,27 @@ fn should_replay(action: &Box) -> bool { true } +fn repeatable_insert(action: &ReplayableAction) -> Option> { + match action { + ReplayableAction::Action(action) => { + if super::InsertBefore.id() == action.id() + || super::InsertAfter.id() == action.id() + || super::InsertFirstNonWhitespace.id() == action.id() + || super::InsertEndOfLine.id() == action.id() + { + Some(super::InsertBefore.boxed_clone()) + } else if super::InsertLineAbove.id() == action.id() + || super::InsertLineBelow.id() == action.id() + { + Some(super::InsertLineBelow.boxed_clone()) + } else { + None + } + } + ReplayableAction::Insertion { .. } => None, + } +} + pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| { Vim::update(cx, |vim, cx| { @@ -28,127 +50,156 @@ pub(crate) fn init(cx: &mut AppContext) { }); }); - cx.add_action(|_: &mut Workspace, _: &Repeat, cx| { - let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| { - let actions = vim.workspace_state.recorded_actions.clone(); - let Some(editor) = vim.active_editor.clone() else { - return None; - }; - let count = vim.take_count(); + cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false)); +} - vim.workspace_state.replaying = true; - - let selection = vim.workspace_state.recorded_selection.clone(); - match selection { - RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => { - vim.workspace_state.recorded_count = None; - vim.switch_mode(Mode::Visual, false, cx) - } - RecordedSelection::VisualLine { .. } => { - vim.workspace_state.recorded_count = None; - vim.switch_mode(Mode::VisualLine, false, cx) - } - RecordedSelection::VisualBlock { .. } => { - vim.workspace_state.recorded_count = None; - vim.switch_mode(Mode::VisualBlock, false, cx) - } - RecordedSelection::None => { - if let Some(count) = count { - vim.workspace_state.recorded_count = Some(count); - } - } - } - - if let Some(editor) = editor.upgrade(cx) { - editor.update(cx, |editor, _| { - editor.show_local_selections = false; - }) - } else { - return None; - } - - Some((actions, editor, selection)) - }) else { - return; +pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { + let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| { + let actions = vim.workspace_state.recorded_actions.clone(); + let Some(editor) = vim.active_editor.clone() else { + return None; }; + let count = vim.take_count(); + let selection = vim.workspace_state.recorded_selection.clone(); match selection { - RecordedSelection::SingleLine { cols } => { - if cols > 1 { - visual_motion(Motion::Right, Some(cols as usize - 1), cx) + RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::Visual, false, cx) + } + RecordedSelection::VisualLine { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualLine, false, cx) + } + RecordedSelection::VisualBlock { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualBlock, false, cx) + } + RecordedSelection::None => { + if let Some(count) = count { + vim.workspace_state.recorded_count = Some(count); } } - RecordedSelection::Visual { rows, cols } => { - visual_motion( - Motion::Down { - display_lines: false, - }, - Some(rows as usize), - cx, - ); - visual_motion( - Motion::StartOfLine { - display_lines: false, - }, - None, - cx, - ); - if cols > 1 { - visual_motion(Motion::Right, Some(cols as usize - 1), cx) - } - } - RecordedSelection::VisualBlock { rows, cols } => { - visual_motion( - Motion::Down { - display_lines: false, - }, - Some(rows as usize), - cx, - ); - if cols > 1 { - visual_motion(Motion::Right, Some(cols as usize - 1), cx); - } - } - RecordedSelection::VisualLine { rows } => { - visual_motion( - Motion::Down { - display_lines: false, - }, - Some(rows as usize), - cx, - ); - } - RecordedSelection::None => {} } - let window = cx.window(); - cx.app_context() - .spawn(move |mut cx| async move { - for action in actions { - match action { - ReplayableAction::Action(action) => { - if should_replay(&action) { - window - .dispatch_action(editor.id(), action.as_ref(), &mut cx) - .ok_or_else(|| anyhow::anyhow!("window was closed")) - } else { - Ok(()) - } - } - ReplayableAction::Insertion { - text, - utf16_range_to_replace, - } => editor.update(&mut cx, |editor, cx| { - editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) - }), - }? - } - window - .dispatch_action(editor.id(), &EndRepeat, &mut cx) - .ok_or_else(|| anyhow::anyhow!("window was closed")) + if let Some(editor) = editor.upgrade(cx) { + editor.update(cx, |editor, _| { + editor.show_local_selections = false; }) - .detach_and_log_err(cx); - }); + } else { + return None; + } + + Some((actions, editor, selection)) + }) else { + return; + }; + + match selection { + RecordedSelection::SingleLine { cols } => { + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::Visual { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + visual_motion( + Motion::StartOfLine { + display_lines: false, + }, + None, + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::VisualBlock { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx); + } + } + RecordedSelection::VisualLine { rows } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + } + RecordedSelection::None => {} + } + + // insert internally uses repeat to handle counts + // vim doesn't treat 3a1 as though you literally repeated a1 + // 3 times, instead it inserts the content thrice at the insert position. + if let Some(to_repeat) = repeatable_insert(&actions[0]) { + if let Some(ReplayableAction::Action(action)) = actions.last() { + if action.id() == NormalBefore.id() { + actions.pop(); + } + } + + let mut new_actions = actions.clone(); + actions[0] = ReplayableAction::Action(to_repeat.boxed_clone()); + + let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1); + + // if we came from insert mode we're just doing repititions 2 onwards. + if from_insert_mode { + count -= 1; + new_actions[0] = actions[0].clone(); + } + + for _ in 1..count { + new_actions.append(actions.clone().as_mut()); + } + new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone())); + actions = new_actions; + } + + Vim::update(cx, |vim, _| vim.workspace_state.replaying = true); + let window = cx.window(); + cx.app_context() + .spawn(move |mut cx| async move { + for action in actions { + match action { + ReplayableAction::Action(action) => { + if should_replay(&action) { + window + .dispatch_action(editor.id(), action.as_ref(), &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + } else { + Ok(()) + } + } + ReplayableAction::Insertion { + text, + utf16_range_to_replace, + } => editor.update(&mut cx, |editor, cx| { + editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) + }), + }? + } + window + .dispatch_action(editor.id(), &EndRepeat, &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + }) + .detach_and_log_err(cx); } #[cfg(test)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 74363bc7b7..fea6f26ef1 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -15,8 +15,8 @@ use anyhow::Result; use collections::{CommandPaletteFilter, HashMap}; use editor::{movement, Editor, EditorMode, Event}; use gpui::{ - actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, - Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, Action, + AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use language::{CursorShape, Point, Selection, SelectionGoal}; pub use mode_indicator::ModeIndicator; @@ -284,6 +284,16 @@ impl Vim { } } + pub fn stop_recording_immediately(&mut self, action: Box) { + if self.workspace_state.recording { + self.workspace_state + .recorded_actions + .push(ReplayableAction::Action(action.boxed_clone())); + self.workspace_state.recording = false; + self.workspace_state.stop_recording_after_next_action = false; + } + } + pub fn record_current_action(&mut self, cx: &mut WindowContext) { self.start_recording(cx); self.stop_recording(); diff --git a/crates/vim/test_data/test_insert_with_counts.json b/crates/vim/test_data/test_insert_with_counts.json new file mode 100644 index 0000000000..470888cf6e --- /dev/null +++ b/crates/vim/test_data/test_insert_with_counts.json @@ -0,0 +1,36 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"5"} +{"Key":"i"} +{"Key":"-"} +{"Key":"escape"} +{"Get":{"state":"----ˇ-hello\n","mode":"Normal"}} +{"Put":{"state":"ˇhello\n"}} +{"Key":"5"} +{"Key":"a"} +{"Key":"-"} +{"Key":"escape"} +{"Get":{"state":"h----ˇ-ello\n","mode":"Normal"}} +{"Key":"4"} +{"Key":"shift-i"} +{"Key":"-"} +{"Key":"escape"} +{"Get":{"state":"---ˇ-h-----ello\n","mode":"Normal"}} +{"Key":"3"} +{"Key":"shift-a"} +{"Key":"-"} +{"Key":"escape"} +{"Get":{"state":"----h-----ello--ˇ-\n","mode":"Normal"}} +{"Put":{"state":"ˇhello\n"}} +{"Key":"3"} +{"Key":"o"} +{"Key":"o"} +{"Key":"i"} +{"Key":"escape"} +{"Get":{"state":"hello\noi\noi\noˇi\n","mode":"Normal"}} +{"Put":{"state":"ˇhello\n"}} +{"Key":"3"} +{"Key":"shift-o"} +{"Key":"o"} +{"Key":"i"} +{"Key":"escape"} +{"Get":{"state":"oi\noi\noˇi\nhello\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_insert_with_repeat.json b/crates/vim/test_data/test_insert_with_repeat.json new file mode 100644 index 0000000000..ac6637633c --- /dev/null +++ b/crates/vim/test_data/test_insert_with_repeat.json @@ -0,0 +1,23 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"3"} +{"Key":"i"} +{"Key":"-"} +{"Key":"escape"} +{"Get":{"state":"--ˇ-hello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"----ˇ--hello\n","mode":"Normal"}} +{"Key":"2"} +{"Key":"."} +{"Get":{"state":"-----ˇ---hello\n","mode":"Normal"}} +{"Put":{"state":"ˇhello\n"}} +{"Key":"2"} +{"Key":"o"} +{"Key":"k"} +{"Key":"k"} +{"Key":"escape"} +{"Get":{"state":"hello\nkk\nkˇk\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}} +{"Key":"1"} +{"Key":"."} +{"Get":{"state":"hello\nkk\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}} From 76d55244a1c28f7a5132717fec99a51373cb5b76 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Sep 2023 18:30:31 -0600 Subject: [PATCH 06/28] Clear counts when switching modes --- crates/vim/src/test.rs | 19 +++++++++++++++++++ crates/vim/src/vim.rs | 4 ++++ crates/vim/test_data/test_clear_counts.json | 7 +++++++ 3 files changed, 30 insertions(+) create mode 100644 crates/vim/test_data/test_clear_counts.json diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 9aa9fffc0a..70a0b7c7a6 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -574,3 +574,22 @@ async fn test_folds(cx: &mut gpui::TestAppContext) { "}) .await; } + +#[gpui::test] +async fn test_clear_counts(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}) + .await; + + cx.simulate_shared_keystrokes(["4", "escape", "3", "d", "l"]) + .await; + cx.set_shared_state(indoc! {" + The quick brown + fox juˇ over + the lazy dog"}) + .await; +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index fea6f26ef1..c6488fe171 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -308,6 +308,9 @@ impl Vim { state.mode = mode; state.operator_stack.clear(); }); + if mode != Mode::Insert { + self.take_count(); + } cx.emit_global(VimEvent::ModeChanged { mode }); @@ -412,6 +415,7 @@ impl Vim { popped_operator } fn clear_operator(&mut self, cx: &mut WindowContext) { + self.take_count(); self.update_state(|state| state.operator_stack.clear()); self.sync_vim_settings(cx); } diff --git a/crates/vim/test_data/test_clear_counts.json b/crates/vim/test_data/test_clear_counts.json new file mode 100644 index 0000000000..8bc78de7d3 --- /dev/null +++ b/crates/vim/test_data/test_clear_counts.json @@ -0,0 +1,7 @@ +{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}} +{"Key":"4"} +{"Key":"escape"} +{"Key":"3"} +{"Key":"d"} +{"Key":"l"} +{"Put":{"state":"The quick brown\nfox juˇ over\nthe lazy dog"}} From c2c521015a0655ce46542b572b341e8e31e2a4b8 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Sep 2023 18:48:24 -0600 Subject: [PATCH 07/28] Fix bug where cursors became invisible if replaying was interrupted --- crates/vim/src/editor_events.rs | 2 ++ crates/vim/src/normal/repeat.rs | 34 ++++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index da5c7d46ed..ae6a2808cf 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -34,7 +34,9 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) { fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) { editor.window().update(cx, |cx| { Vim::update(cx, |vim, cx| { + vim.clear_operator(cx); vim.workspace_state.recording = false; + vim.workspace_state.recorded_actions.clear(); if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor == editor.clone() { vim.active_editor = None; diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 6954ace71f..d7d8c30773 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -43,9 +43,6 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| { Vim::update(cx, |vim, cx| { vim.workspace_state.replaying = false; - vim.update_active_editor(cx, |editor, _| { - editor.show_local_selections = true; - }); vim.switch_mode(Mode::Normal, false, cx) }); }); @@ -56,6 +53,10 @@ pub(crate) fn init(cx: &mut AppContext) { pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| { let actions = vim.workspace_state.recorded_actions.clone(); + if actions.is_empty() { + return None; + } + let Some(editor) = vim.active_editor.clone() else { return None; }; @@ -82,14 +83,6 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { } } - if let Some(editor) = editor.upgrade(cx) { - editor.update(cx, |editor, _| { - editor.show_local_selections = false; - }) - } else { - return None; - } - Some((actions, editor, selection)) }) else { return; @@ -176,6 +169,9 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { let window = cx.window(); cx.app_context() .spawn(move |mut cx| async move { + editor.update(&mut cx, |editor, _| { + editor.show_local_selections = false; + })?; for action in actions { match action { ReplayableAction::Action(action) => { @@ -195,6 +191,9 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { }), }? } + editor.update(&mut cx, |editor, _| { + editor.show_local_selections = true; + })?; window .dispatch_action(editor.id(), &EndRepeat, &mut cx) .ok_or_else(|| anyhow::anyhow!("window was closed")) @@ -513,4 +512,17 @@ mod test { }) .await; } + + #[gpui::test] + async fn test_record_interrupted( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇhello\n", Mode::Normal); + cx.simulate_keystrokes(["4", "i", "j", "cmd-shift-p", "escape", "escape"]); + deterministic.run_until_parked(); + cx.assert_state("ˇjhello\n", Mode::Normal); + } } From 7daed1b2c3a6b2ce4b8255c6b56fdc696de8a202 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 12 Sep 2023 09:56:23 -0600 Subject: [PATCH 08/28] Fix 0 used in a count --- crates/vim/src/insert.rs | 2 +- crates/vim/src/motion.rs | 2 +- crates/vim/src/normal.rs | 10 +++---- crates/vim/src/normal/case.rs | 2 +- crates/vim/src/normal/repeat.rs | 4 +-- crates/vim/src/normal/scroll.rs | 2 +- crates/vim/src/normal/search.rs | 4 +-- crates/vim/src/normal/substitute.rs | 4 +-- crates/vim/src/state.rs | 1 + crates/vim/src/test.rs | 27 ++++++++++++++++++- .../src/test/neovim_backed_test_context.rs | 14 ++++++++++ crates/vim/src/vim.rs | 19 ++++++------- crates/vim/test_data/test_clear_counts.json | 2 +- crates/vim/test_data/test_dot_repeat.json | 2 +- crates/vim/test_data/test_zero.json | 7 +++++ 15 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 crates/vim/test_data/test_zero.json diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 7495b302a2..fb567fab6a 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -12,7 +12,7 @@ pub fn init(cx: &mut AppContext) { fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext) { let should_repeat = Vim::update(cx, |vim, cx| { - let count = vim.take_count().unwrap_or(1); + let count = vim.take_count(cx).unwrap_or(1); vim.stop_recording_immediately(action.boxed_clone()); if count <= 1 || vim.workspace_state.replaying { vim.update_active_editor(cx, |editor, cx| { diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 6821ec64e5..3e65e6d504 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -229,7 +229,7 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| vim.pop_operator(cx)); } - let count = Vim::update(cx, |vim, _| vim.take_count()); + let count = Vim::update(cx, |vim, cx| vim.take_count(cx)); let operator = Vim::read(cx).active_operator(); match Vim::read(cx).state().mode { Mode::Normal => normal_motion(motion, operator, count, cx), diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 16a4150dab..2034436694 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -68,21 +68,21 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.take_count(); + let times = vim.take_count(cx); delete_motion(vim, Motion::Left, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.take_count(); + let times = vim.take_count(cx); delete_motion(vim, Motion::Right, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { Vim::update(cx, |vim, cx| { vim.start_recording(cx); - let times = vim.take_count(); + let times = vim.take_count(cx); change_motion( vim, Motion::EndOfLine { @@ -96,7 +96,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.take_count(); + let times = vim.take_count(cx); delete_motion( vim, Motion::EndOfLine { @@ -110,7 +110,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let mut times = vim.take_count().unwrap_or(1); + let mut times = vim.take_count(cx).unwrap_or(1); if vim.state().mode.is_visual() { times = 1; } else if times > 1 { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 34b81fbb4c..22d09f8359 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -8,7 +8,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let count = vim.take_count().unwrap_or(1) as u32; + let count = vim.take_count(cx).unwrap_or(1) as u32; vim.update_active_editor(cx, |editor, cx| { let mut ranges = Vec::new(); let mut cursor_positions = Vec::new(); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index d7d8c30773..df9e9a32ad 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -60,7 +60,7 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { let Some(editor) = vim.active_editor.clone() else { return None; }; - let count = vim.take_count(); + let count = vim.take_count(cx); let selection = vim.workspace_state.recorded_selection.clone(); match selection { @@ -253,7 +253,7 @@ mod test { deterministic.run_until_parked(); cx.simulate_shared_keystrokes(["."]).await; deterministic.run_until_parked(); - cx.set_shared_state("THE QUICK ˇbrown fox").await; + cx.assert_shared_state("THE QUICK ˇbrown fox").await; } #[gpui::test] diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 6319ba1663..877fff328b 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -48,7 +48,7 @@ pub fn init(cx: &mut AppContext) { fn scroll(cx: &mut ViewContext, by: fn(c: Option) -> ScrollAmount) { Vim::update(cx, |vim, cx| { - let amount = by(vim.take_count().map(|c| c as f32)); + let amount = by(vim.take_count(cx).map(|c| c as f32)); vim.update_active_editor(cx, |editor, cx| scroll_editor(editor, &amount, cx)); }) } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index b488a879f2..d0c8a56249 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -52,7 +52,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext() { search_bar.update(cx, |search_bar, cx| { @@ -119,7 +119,7 @@ pub fn move_to_internal( ) { Vim::update(cx, |vim, cx| { let pane = workspace.active_pane().clone(); - let count = vim.take_count().unwrap_or(1); + let count = vim.take_count(cx).unwrap_or(1); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { let search = search_bar.update(cx, |search_bar, cx| { diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 26aff7baa4..bb6e1abf92 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -11,7 +11,7 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { Vim::update(cx, |vim, cx| { vim.start_recording(cx); - let count = vim.take_count(); + let count = vim.take_count(cx); substitute(vim, count, vim.state().mode == Mode::VisualLine, cx); }) }); @@ -22,7 +22,7 @@ pub(crate) fn init(cx: &mut AppContext) { if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } - let count = vim.take_count(); + let count = vim.take_count(cx); substitute(vim, count, true, cx) }) }); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 8fd4049767..2cb5e058e8 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -186,6 +186,7 @@ impl EditorState { if self.active_operator().is_none() && self.pre_count.is_some() || self.active_operator().is_some() && self.post_count.is_some() { + dbg!("VimCount"); context.add_identifier("VimCount"); } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 70a0b7c7a6..a708d3c12a 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -587,9 +587,34 @@ async fn test_clear_counts(cx: &mut gpui::TestAppContext) { cx.simulate_shared_keystrokes(["4", "escape", "3", "d", "l"]) .await; - cx.set_shared_state(indoc! {" + cx.assert_shared_state(indoc! {" The quick brown fox juˇ over the lazy dog"}) .await; } + +#[gpui::test] +async fn test_zero(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + The quˇick brown + fox jumps over + the lazy dog"}) + .await; + + cx.simulate_shared_keystrokes(["0"]).await; + cx.assert_shared_state(indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog"}) + .await; + + cx.simulate_shared_keystrokes(["1", "0", "l"]).await; + cx.assert_shared_state(indoc! {" + The quick ˇbrown + fox jumps over + the lazy dog"}) + .await; +} diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index b433a6bfc0..97df989e4d 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -68,6 +68,8 @@ pub struct NeovimBackedTestContext<'a> { last_set_state: Option, recent_keystrokes: Vec, + + is_dirty: bool, } impl<'a> NeovimBackedTestContext<'a> { @@ -81,6 +83,7 @@ impl<'a> NeovimBackedTestContext<'a> { last_set_state: None, recent_keystrokes: Default::default(), + is_dirty: false, } } @@ -128,6 +131,7 @@ impl<'a> NeovimBackedTestContext<'a> { self.last_set_state = Some(marked_text.to_string()); self.recent_keystrokes = Vec::new(); self.neovim.set_state(marked_text).await; + self.is_dirty = true; context_handle } @@ -153,6 +157,7 @@ impl<'a> NeovimBackedTestContext<'a> { } pub async fn assert_shared_state(&mut self, marked_text: &str) { + self.is_dirty = false; let marked_text = marked_text.replace("•", " "); let neovim = self.neovim_state().await; let editor = self.editor_state(); @@ -258,6 +263,7 @@ impl<'a> NeovimBackedTestContext<'a> { } pub async fn assert_state_matches(&mut self) { + self.is_dirty = false; let neovim = self.neovim_state().await; let editor = self.editor_state(); let initial_state = self @@ -383,6 +389,14 @@ impl<'a> DerefMut for NeovimBackedTestContext<'a> { } } +impl<'a> Drop for NeovimBackedTestContext<'a> { + fn drop(&mut self) { + if self.is_dirty { + panic!("Test context was dropped after set_shared_state before assert_shared_state") + } + } +} + #[cfg(test)] mod test { use gpui::TestAppContext; diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c6488fe171..7911717765 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -73,7 +73,7 @@ pub fn init(cx: &mut AppContext) { }, ); cx.add_action(|_: &mut Workspace, n: &Number, cx: _| { - Vim::update(cx, |vim, _| vim.push_count_digit(n.0)); + Vim::update(cx, |vim, cx| vim.push_count_digit(n.0, cx)); }); cx.add_action(|_: &mut Workspace, _: &Tab, cx| { @@ -228,13 +228,7 @@ impl Vim { let editor = self.active_editor.clone()?.upgrade(cx)?; Some(editor.update(cx, update)) } - // ~, shift-j, x, shift-x, p - // shift-c, shift-d, shift-i, i, a, o, shift-o, s - // c, d - // r - // TODO: shift-j? - // pub fn start_recording(&mut self, cx: &mut WindowContext) { if !self.workspace_state.replaying { self.workspace_state.recording = true; @@ -309,7 +303,7 @@ impl Vim { state.operator_stack.clear(); }); if mode != Mode::Insert { - self.take_count(); + self.take_count(cx); } cx.emit_global(VimEvent::ModeChanged { mode }); @@ -363,7 +357,7 @@ impl Vim { }); } - fn push_count_digit(&mut self, number: usize) { + fn push_count_digit(&mut self, number: usize, cx: &mut WindowContext) { if self.active_operator().is_some() { self.update_state(|state| { state.post_count = Some(state.post_count.unwrap_or(0) * 10 + number) @@ -373,9 +367,11 @@ impl Vim { state.pre_count = Some(state.pre_count.unwrap_or(0) * 10 + number) }) } + // update the keymap so that 0 works + self.sync_vim_settings(cx) } - fn take_count(&mut self) -> Option { + fn take_count(&mut self, cx: &mut WindowContext) -> Option { if self.workspace_state.replaying { return self.workspace_state.recorded_count; } @@ -390,6 +386,7 @@ impl Vim { if self.workspace_state.recording { self.workspace_state.recorded_count = count; } + self.sync_vim_settings(cx); count } @@ -415,7 +412,7 @@ impl Vim { popped_operator } fn clear_operator(&mut self, cx: &mut WindowContext) { - self.take_count(); + self.take_count(cx); self.update_state(|state| state.operator_stack.clear()); self.sync_vim_settings(cx); } diff --git a/crates/vim/test_data/test_clear_counts.json b/crates/vim/test_data/test_clear_counts.json index 8bc78de7d3..6ef6b36017 100644 --- a/crates/vim/test_data/test_clear_counts.json +++ b/crates/vim/test_data/test_clear_counts.json @@ -4,4 +4,4 @@ {"Key":"3"} {"Key":"d"} {"Key":"l"} -{"Put":{"state":"The quick brown\nfox juˇ over\nthe lazy dog"}} +{"Get":{"state":"The quick brown\nfox juˇ over\nthe lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json index f1a1a3c138..331ef52ecb 100644 --- a/crates/vim/test_data/test_dot_repeat.json +++ b/crates/vim/test_data/test_dot_repeat.json @@ -35,4 +35,4 @@ {"Key":"."} {"Put":{"state":"THE QUIˇck brown fox"}} {"Key":"."} -{"Put":{"state":"THE QUICK ˇbrown fox"}} +{"Get":{"state":"THE QUICK ˇbrown fox","mode":"Normal"}} diff --git a/crates/vim/test_data/test_zero.json b/crates/vim/test_data/test_zero.json new file mode 100644 index 0000000000..bc1253deb5 --- /dev/null +++ b/crates/vim/test_data/test_zero.json @@ -0,0 +1,7 @@ +{"Put":{"state":"The quˇick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"0"} +{"Get":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"1"} +{"Key":"0"} +{"Key":"l"} +{"Get":{"state":"The quick ˇbrown\nfox jumps over\nthe lazy dog","mode":"Normal"}} From dcaba9d9e7d981397c048a8e3e08011d62f97100 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 12 Sep 2023 10:13:24 -0600 Subject: [PATCH 09/28] Remove supported exception (and refactor tests to be more linear) --- crates/vim/src/normal/change.rs | 170 ++++++++++-------- crates/vim/src/normal/delete.rs | 48 ++--- .../src/test/neovim_backed_test_context.rs | 10 +- 3 files changed, 123 insertions(+), 105 deletions(-) diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 836ce1492b..e9f3001392 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -121,7 +121,7 @@ fn expand_changed_word_selection( mod test { use indoc::indoc; - use crate::test::{ExemptionFeatures, NeovimBackedTestContext}; + use crate::test::NeovimBackedTestContext; #[gpui::test] async fn test_change_h(cx: &mut gpui::TestAppContext) { @@ -239,150 +239,178 @@ mod test { #[gpui::test] async fn test_change_0(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "0"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_neovim_compatible( + indoc! {" The qˇuick - brown fox"}) - .await; - cx.assert(indoc! {" + brown fox"}, + ["c", "0"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick ˇ - brown fox"}) - .await; + brown fox"}, + ["c", "0"], + ) + .await; } #[gpui::test] async fn test_change_k(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "k"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_neovim_compatible( + indoc! {" The quick brown ˇfox - jumps over"}) - .await; - cx.assert(indoc! {" + jumps over"}, + ["c", "k"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick brown fox - jumps ˇover"}) - .await; - cx.assert_exempted( + jumps ˇover"}, + ["c", "k"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The qˇuick brown fox jumps over"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "k"], ) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" ˇ brown fox jumps over"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "k"], ) .await; } #[gpui::test] async fn test_change_j(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "j"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_neovim_compatible( + indoc! {" The quick brown ˇfox - jumps over"}) - .await; - cx.assert_exempted( + jumps over"}, + ["c", "j"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The quick brown fox jumps ˇover"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "j"], ) .await; - cx.assert(indoc! {" + cx.assert_neovim_compatible( + indoc! {" The qˇuick brown fox - jumps over"}) - .await; - cx.assert_exempted( + jumps over"}, + ["c", "j"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The quick brown fox ˇ"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "j"], ) .await; } #[gpui::test] async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx) - .await - .binding(["c", "shift-g"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}) - .await; - cx.assert(indoc! {" + the lazy"}, + ["c", "shift-g"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}) - .await; - cx.assert_exempted( + the lazy"}, + ["c", "shift-g"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The quick brown fox jumps over the lˇazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "shift-g"], ) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" The quick brown fox jumps over ˇ"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "shift-g"], ) .await; } #[gpui::test] async fn test_change_gg(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx) - .await - .binding(["c", "g", "g"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}) - .await; - cx.assert(indoc! {" + the lazy"}, + ["c", "g", "g"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick brown fox jumps over - the lˇazy"}) - .await; - cx.assert_exempted( + the lˇazy"}, + ["c", "g", "g"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The qˇuick brown fox jumps over the lazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "g", "g"], ) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" ˇ brown fox jumps over the lazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["c", "g", "g"], ) .await; } @@ -427,27 +455,17 @@ mod test { async fn test_repeated_cb(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; - cx.add_initial_state_exemptions( - indoc! {" - ˇThe quick brown - - fox jumps-over - the lazy dog - "}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, - ); - for count in 1..=5 { - cx.assert_binding_matches_all( - ["c", &count.to_string(), "b"], - indoc! {" - ˇThe quˇickˇ browˇn - ˇ - ˇfox ˇjumpsˇ-ˇoˇver - ˇthe lazy dog - "}, - ) - .await; + for marked_text in cx.each_marked_position(indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}) + { + cx.assert_neovim_compatible(&marked_text, ["c", &count.to_string(), "b"]) + .await; + } } } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 1126eb1155..19ea6af875 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -278,37 +278,41 @@ mod test { #[gpui::test] async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx) - .await - .binding(["d", "shift-g"]); - cx.assert(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}) - .await; - cx.assert(indoc! {" + the lazy"}, + ["d", "shift-g"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}) - .await; - cx.assert_exempted( + the lazy"}, + ["d", "shift-g"], + ) + .await; + cx.assert_neovim_compatible( indoc! {" The quick brown fox jumps over the lˇazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["d", "shift-g"], ) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" The quick brown fox jumps over ˇ"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + ["d", "shift-g"], ) .await; } @@ -318,34 +322,32 @@ mod test { let mut cx = NeovimBackedTestContext::new(cx) .await .binding(["d", "g", "g"]); - cx.assert(indoc! {" + cx.assert_neovim_compatible(indoc! {" The quick brownˇ fox jumps over - the lazy"}) + the lazy"}, ["d", "g", "g"]) .await; - cx.assert(indoc! {" + cx.assert_neovim_compatible(indoc! {" The quick brown fox jumps over - the lˇazy"}) + the lˇazy"}, ["d", "g", "g"]) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" The qˇuick brown fox jumps over - the lazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + the lazy"},["d", "g", "g"] ) .await; - cx.assert_exempted( + cx.assert_neovim_compatible( indoc! {" ˇ brown fox jumps over - the lazy"}, - ExemptionFeatures::OperatorAbortsOnFailedMotion, + the lazy"},["d", "g", "g"] ) .await; } diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 97df989e4d..0df5c32136 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -13,10 +13,7 @@ use util::test::{generate_marked_text, marked_text_offsets}; use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext}; use crate::state::Mode; -pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[ - ExemptionFeatures::DeletionOnEmptyLine, - ExemptionFeatures::OperatorAbortsOnFailedMotion, -]; +pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[ExemptionFeatures::DeletionOnEmptyLine]; /// Enum representing features we have tests for but which don't work, yet. Used /// to add exemptions and automatically @@ -25,8 +22,6 @@ pub enum ExemptionFeatures { // MOTIONS // Deletions on empty lines miss some newlines DeletionOnEmptyLine, - // When a motion fails, it should should not apply linewise operations - OperatorAbortsOnFailedMotion, // When an operator completes at the end of the file, an extra newline is left OperatorLastNewlineRemains, // Deleting a word on an empty line doesn't remove the newline @@ -389,6 +384,9 @@ impl<'a> DerefMut for NeovimBackedTestContext<'a> { } } +// a common mistake in tests is to call set_shared_state when +// you mean asswert_shared_state. This notices that and lets +// you know. impl<'a> Drop for NeovimBackedTestContext<'a> { fn drop(&mut self) { if self.is_dirty { From b0facf8e1e84ec65efcb5e67fe0e9f9c1ef797e5 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 12 Sep 2023 08:35:58 -0400 Subject: [PATCH 10/28] Use unbounded channel(s) for LSP binary status messaging Co-Authored-By: Antonio Scandurra --- crates/language/src/language.rs | 79 ++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 2193b5c07e..07bea434e0 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -13,7 +13,7 @@ use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use collections::{HashMap, HashSet}; use futures::{ - channel::oneshot, + channel::{mpsc, oneshot}, future::{BoxFuture, Shared}, FutureExt, TryFutureExt as _, }; @@ -48,9 +48,6 @@ use unicase::UniCase; use util::{http::HttpClient, paths::PathExt}; use util::{post_inc, ResultExt, TryFutureExt as _, UnwrapFuture}; -#[cfg(any(test, feature = "test-support"))] -use futures::channel::mpsc; - pub use buffer::Operation; pub use buffer::*; pub use diagnostic_set::DiagnosticEntry; @@ -64,6 +61,27 @@ pub fn init(cx: &mut AppContext) { language_settings::init(cx); } +#[derive(Clone, Default)] +struct LspBinaryStatusSender { + txs: Arc, LanguageServerBinaryStatus)>>>>, +} + +impl LspBinaryStatusSender { + fn subscribe(&self) -> mpsc::UnboundedReceiver<(Arc, LanguageServerBinaryStatus)> { + let (tx, rx) = mpsc::unbounded(); + self.txs.lock().push(tx); + rx + } + + fn send(&self, language: Arc, status: LanguageServerBinaryStatus) { + let mut txs = self.txs.lock(); + txs.retain(|tx| { + tx.unbounded_send((language.clone(), status.clone())) + .is_ok() + }); + } +} + thread_local! { static PARSER: RefCell = RefCell::new(Parser::new()); } @@ -594,14 +612,13 @@ struct AvailableLanguage { pub struct LanguageRegistry { state: RwLock, language_server_download_dir: Option>, - lsp_binary_statuses_tx: async_broadcast::Sender<(Arc, LanguageServerBinaryStatus)>, - lsp_binary_statuses_rx: async_broadcast::Receiver<(Arc, LanguageServerBinaryStatus)>, login_shell_env_loaded: Shared>, #[allow(clippy::type_complexity)] lsp_binary_paths: Mutex< HashMap>>>>, >, executor: Option>, + lsp_binary_status_tx: LspBinaryStatusSender, } struct LanguageRegistryState { @@ -624,7 +641,6 @@ pub struct PendingLanguageServer { impl LanguageRegistry { pub fn new(login_shell_env_loaded: Task<()>) -> Self { - let (lsp_binary_statuses_tx, lsp_binary_statuses_rx) = async_broadcast::broadcast(16); Self { state: RwLock::new(LanguageRegistryState { next_language_server_id: 0, @@ -638,11 +654,10 @@ impl LanguageRegistry { reload_count: 0, }), language_server_download_dir: None, - lsp_binary_statuses_tx, - lsp_binary_statuses_rx, login_shell_env_loaded: login_shell_env_loaded.shared(), lsp_binary_paths: Default::default(), executor: None, + lsp_binary_status_tx: Default::default(), } } @@ -918,8 +933,8 @@ impl LanguageRegistry { let container_dir: Arc = Arc::from(download_dir.join(adapter.name.0.as_ref())); let root_path = root_path.clone(); let adapter = adapter.clone(); - let lsp_binary_statuses = self.lsp_binary_statuses_tx.clone(); let login_shell_env_loaded = self.login_shell_env_loaded.clone(); + let lsp_binary_statuses = self.lsp_binary_status_tx.clone(); let task = { let container_dir = container_dir.clone(); @@ -976,8 +991,8 @@ impl LanguageRegistry { pub fn language_server_binary_statuses( &self, - ) -> async_broadcast::Receiver<(Arc, LanguageServerBinaryStatus)> { - self.lsp_binary_statuses_rx.clone() + ) -> mpsc::UnboundedReceiver<(Arc, LanguageServerBinaryStatus)> { + self.lsp_binary_status_tx.subscribe() } pub fn delete_server_container( @@ -1054,7 +1069,7 @@ async fn get_binary( language: Arc, delegate: Arc, container_dir: Arc, - statuses: async_broadcast::Sender<(Arc, LanguageServerBinaryStatus)>, + statuses: LspBinaryStatusSender, mut cx: AsyncAppContext, ) -> Result { if !container_dir.exists() { @@ -1081,19 +1096,15 @@ async fn get_binary( .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref()) .await { - statuses - .broadcast((language.clone(), LanguageServerBinaryStatus::Cached)) - .await?; + statuses.send(language.clone(), LanguageServerBinaryStatus::Cached); return Ok(binary); } else { - statuses - .broadcast(( - language.clone(), - LanguageServerBinaryStatus::Failed { - error: format!("{:?}", error), - }, - )) - .await?; + statuses.send( + language.clone(), + LanguageServerBinaryStatus::Failed { + error: format!("{:?}", error), + }, + ); } } @@ -1105,27 +1116,21 @@ async fn fetch_latest_binary( language: Arc, delegate: &dyn LspAdapterDelegate, container_dir: &Path, - lsp_binary_statuses_tx: async_broadcast::Sender<(Arc, LanguageServerBinaryStatus)>, + lsp_binary_statuses_tx: LspBinaryStatusSender, ) -> Result { let container_dir: Arc = container_dir.into(); - lsp_binary_statuses_tx - .broadcast(( - language.clone(), - LanguageServerBinaryStatus::CheckingForUpdate, - )) - .await?; + lsp_binary_statuses_tx.send( + language.clone(), + LanguageServerBinaryStatus::CheckingForUpdate, + ); let version_info = adapter.fetch_latest_server_version(delegate).await?; - lsp_binary_statuses_tx - .broadcast((language.clone(), LanguageServerBinaryStatus::Downloading)) - .await?; + lsp_binary_statuses_tx.send(language.clone(), LanguageServerBinaryStatus::Downloading); let binary = adapter .fetch_server_binary(version_info, container_dir.to_path_buf(), delegate) .await?; - lsp_binary_statuses_tx - .broadcast((language.clone(), LanguageServerBinaryStatus::Downloaded)) - .await?; + lsp_binary_statuses_tx.send(language.clone(), LanguageServerBinaryStatus::Downloaded); Ok(binary) } From 0958def770cc592042bd8b66530ed4f55cd907e4 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 12 Sep 2023 11:24:48 -0600 Subject: [PATCH 11/28] Remove another supported exemption --- crates/vim/src/normal.rs | 24 ++++++++++--------- crates/vim/src/normal/delete.rs | 24 ++++++++++++------- .../src/test/neovim_backed_test_context.rs | 4 +--- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 2034436694..c8d12f8ee3 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -356,7 +356,7 @@ mod test { use crate::{ state::Mode::{self}, - test::{ExemptionFeatures, NeovimBackedTestContext}, + test::NeovimBackedTestContext, }; #[gpui::test] @@ -762,20 +762,22 @@ mod test { #[gpui::test] async fn test_dd(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "d"]); - cx.assert("ˇ").await; - cx.assert("The ˇquick").await; - cx.assert_all(indoc! {" - The qˇuick - brown ˇfox - jumps ˇover"}) - .await; - cx.assert_exempted( + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_neovim_compatible("ˇ", ["d", "d"]).await; + cx.assert_neovim_compatible("The ˇquick", ["d", "d"]).await; + for marked_text in cx.each_marked_position(indoc! {" + The qˇuick + brown ˇfox + jumps ˇover"}) + { + cx.assert_neovim_compatible(&marked_text, ["d", "d"]).await; + } + cx.assert_neovim_compatible( indoc! {" The quick ˇ brown fox"}, - ExemptionFeatures::DeletionOnEmptyLine, + ["d", "d"], ) .await; } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 19ea6af875..848e9f725d 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -322,24 +322,31 @@ mod test { let mut cx = NeovimBackedTestContext::new(cx) .await .binding(["d", "g", "g"]); - cx.assert_neovim_compatible(indoc! {" + cx.assert_neovim_compatible( + indoc! {" The quick brownˇ fox jumps over - the lazy"}, ["d", "g", "g"]) - .await; - cx.assert_neovim_compatible(indoc! {" + the lazy"}, + ["d", "g", "g"], + ) + .await; + cx.assert_neovim_compatible( + indoc! {" The quick brown fox jumps over - the lˇazy"}, ["d", "g", "g"]) - .await; + the lˇazy"}, + ["d", "g", "g"], + ) + .await; cx.assert_neovim_compatible( indoc! {" The qˇuick brown fox jumps over - the lazy"},["d", "g", "g"] + the lazy"}, + ["d", "g", "g"], ) .await; cx.assert_neovim_compatible( @@ -347,7 +354,8 @@ mod test { ˇ brown fox jumps over - the lazy"},["d", "g", "g"] + the lazy"}, + ["d", "g", "g"], ) .await; } diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 0df5c32136..e58f805a02 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -13,15 +13,13 @@ use util::test::{generate_marked_text, marked_text_offsets}; use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext}; use crate::state::Mode; -pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[ExemptionFeatures::DeletionOnEmptyLine]; +pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[]; /// Enum representing features we have tests for but which don't work, yet. Used /// to add exemptions and automatically #[derive(PartialEq, Eq)] pub enum ExemptionFeatures { // MOTIONS - // Deletions on empty lines miss some newlines - DeletionOnEmptyLine, // When an operator completes at the end of the file, an extra newline is left OperatorLastNewlineRemains, // Deleting a word on an empty line doesn't remove the newline From c6f293076efa72745de30ace1913279582696717 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 12 Sep 2023 15:14:49 -0400 Subject: [PATCH 12/28] Avoid keeping stale LSP progress indicator state when server is removed --- crates/diagnostics/src/items.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 89b4469d42..c3733018b6 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -32,7 +32,8 @@ impl DiagnosticIndicator { this.in_progress_checks.insert(*language_server_id); cx.notify(); } - project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } + | project::Event::LanguageServerRemoved(language_server_id) => { this.summary = project.read(cx).diagnostic_summary(cx); this.in_progress_checks.remove(language_server_id); cx.notify(); From a63b78d5a0b25497646dbfd0144eeb068e49e025 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 12 Sep 2023 22:08:39 +0200 Subject: [PATCH 13/28] Replace in buffer adjustments (#2960) This PR addresses feedback from @maxbrunsfeld on new replace in buffer. It fixes: - missing padding surrounding replace input. - missing padding around replace buttons. - missing `.notify` call which made the replace fields not show up immediately sometimes. Release Notes: - N/A --------- Co-authored-by: Max --- assets/keymaps/default.json | 9 ++++++++- crates/search/src/buffer_search.rs | 18 +++++++++++++++++- styles/src/style_tree/search.ts | 8 ++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index fa62a74f3f..dd5544f034 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -231,7 +231,14 @@ } }, { - "context": "BufferSearchBar > Editor", + "context": "BufferSearchBar && in_replace", + "bindings": { + "enter": "search::ReplaceNext", + "cmd-enter": "search::ReplaceAll" + } + }, + { + "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", "down": "search::NextHistoryQuery" diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 6a227812d1..9ae2b20f7a 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -101,6 +101,21 @@ impl View for BufferSearchBar { "BufferSearchBar" } + fn update_keymap_context( + &self, + keymap: &mut gpui::keymap_matcher::KeymapContext, + cx: &AppContext, + ) { + Self::reset_to_default_keymap_context(keymap); + let in_replace = self + .replacement_editor + .read_with(cx, |_, cx| cx.is_self_focused()) + .unwrap_or(false); + if in_replace { + keymap.add_identifier("in_replace"); + } + } + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { cx.focus(&self.query_editor); @@ -868,9 +883,10 @@ impl BufferSearchBar { cx.propagate_action(); } } - fn toggle_replace(&mut self, _: &ToggleReplace, _: &mut ViewContext) { + fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext) { if let Some(_) = &self.active_searchable_item { self.replace_is_active = !self.replace_is_active; + cx.notify(); } } fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { diff --git a/styles/src/style_tree/search.ts b/styles/src/style_tree/search.ts index bc95b91819..b0ac023c09 100644 --- a/styles/src/style_tree/search.ts +++ b/styles/src/style_tree/search.ts @@ -36,6 +36,7 @@ export default function search(): any { left: 10, right: 4, }, + margin: { right: SEARCH_ROW_SPACING } } const include_exclude_editor = { @@ -201,7 +202,6 @@ export default function search(): any { }, option_button_group: { padding: { - left: SEARCH_ROW_SPACING, right: SEARCH_ROW_SPACING, }, }, @@ -375,7 +375,11 @@ export default function search(): any { search_bar_row_height: 34, search_row_spacing: 8, option_button_height: 22, - modes_container: {}, + modes_container: { + padding: { + right: SEARCH_ROW_SPACING, + } + }, replace_icon: { icon: { color: foreground(theme.highest, "disabled"), From 54838664ae59391c6a18fbb4c5d8e87d761b1767 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 12 Sep 2023 21:04:59 -0700 Subject: [PATCH 14/28] Retrieve load balancer certificate id from DigitalOcean on each deploy Co-authored-by: Mikayla --- crates/collab/k8s/manifest.template.yml | 3 ++- script/deploy | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 79dd2b8851..8f6915019b 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -3,6 +3,7 @@ apiVersion: v1 kind: Namespace metadata: name: ${ZED_KUBE_NAMESPACE} + --- kind: Service apiVersion: v1 @@ -11,7 +12,7 @@ metadata: name: collab annotations: service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443" - service.beta.kubernetes.io/do-loadbalancer-certificate-id: "08d9d8ce-761f-4ab3-bc78-4923ab5b0e33" + service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID} spec: type: LoadBalancer selector: diff --git a/script/deploy b/script/deploy index f675da6a99..efccb18506 100755 --- a/script/deploy +++ b/script/deploy @@ -13,6 +13,7 @@ version=$2 export_vars_for_environment ${environment} image_id=$(image_id_for_version ${version}) +export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header) export ZED_KUBE_NAMESPACE=${environment} export ZED_IMAGE_ID=${image_id} From 94db0be3ec5dd821782841a010b17a7e43cc2bb2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 12 Sep 2023 21:06:43 -0700 Subject: [PATCH 15/28] Start work on deploying pgAdmin to k8s cluster Co-authored-by: Mikayla --- crates/collab/k8s/manifest.template.yml | 115 ++++++++++++++++++++++++ script/deploy | 2 +- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 8f6915019b..5af1aad450 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -22,6 +22,26 @@ spec: protocol: TCP port: 443 targetPort: 8080 + +--- +kind: Service +apiVersion: v1 +metadata: + namespace: ${ZED_KUBE_NAMESPACE} + name: pgadmin + annotations: + service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443" + service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID} +spec: + type: LoadBalancer + selector: + app: pgadmin + ports: + - name: web + protocol: TCP + port: 443 + targetPort: 8080 + --- apiVersion: apps/v1 kind: Deployment @@ -118,3 +138,98 @@ spec: # FIXME - Switch to the more restrictive `PERFMON` capability. # This capability isn't yet available in a stable version of Debian. add: ["SYS_ADMIN"] + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + namespace: ${ZED_KUBE_NAMESPACE} + name: pgadmin + +spec: + replicas: 1 + selector: + matchLabels: + app: pgadmin + template: + metadata: + labels: + app: pgadmin + spec: + securityContext: + runAsUser: 0 + containers: + - name: pgadmin + image: "dpage/pgadmin4" + ports: + - containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /misc/ping + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /misc/ping + port: 8080 + initialDelaySeconds: 1 + periodSeconds: 1 + command: ['/bin/sh', '-c'] + args: + - | + set -e + + python3 - < Date: Mon, 11 Sep 2023 16:33:25 +0200 Subject: [PATCH 16/28] Never use the indentation that comes from OpenAI --- crates/ai/src/assistant.rs | 51 +++--- crates/ai/src/codegen.rs | 346 +++++++++++++++++++++++++------------ 2 files changed, 261 insertions(+), 136 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1d56a6308c..6d4fce2f6d 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,6 +1,6 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel}, - codegen::{self, Codegen, OpenAICompletionProvider}, + codegen::{self, Codegen, CodegenKind, OpenAICompletionProvider}, stream_completion, MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, }; @@ -270,24 +270,28 @@ impl AssistantPanel { let inline_assist_id = post_inc(&mut self.next_inline_assist_id); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let selection = editor.read(cx).selections.newest_anchor().clone(); - let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot); let provider = Arc::new(OpenAICompletionProvider::new( api_key, cx.background().clone(), )); - let codegen = - cx.add_model(|cx| Codegen::new(editor.read(cx).buffer().clone(), range, provider, cx)); - let assist_kind = if editor.read(cx).selections.newest::(cx).is_empty() { - InlineAssistKind::Generate + let selection = editor.read(cx).selections.newest_anchor().clone(); + let codegen_kind = if editor.read(cx).selections.newest::(cx).is_empty() { + CodegenKind::Generate { + position: selection.start, + } } else { - InlineAssistKind::Transform + CodegenKind::Transform { + range: selection.start..selection.end, + } }; + let codegen = cx.add_model(|cx| { + Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx) + }); + let measurements = Rc::new(Cell::new(BlockMeasurements::default())); let inline_assistant = cx.add_view(|cx| { let assistant = InlineAssistant::new( inline_assist_id, - assist_kind, measurements.clone(), self.include_conversation_in_next_inline_assist, self.inline_prompt_history.clone(), @@ -330,7 +334,6 @@ impl AssistantPanel { self.pending_inline_assists.insert( inline_assist_id, PendingInlineAssist { - kind: assist_kind, editor: editor.downgrade(), inline_assistant: Some((block_id, inline_assistant.clone())), codegen: codegen.clone(), @@ -348,6 +351,14 @@ impl AssistantPanel { } } }), + cx.observe(&codegen, { + let editor = editor.downgrade(); + move |this, _, cx| { + if let Some(editor) = editor.upgrade(cx) { + this.update_highlights_for_editor(&editor, cx); + } + } + }), cx.subscribe(&codegen, move |this, codegen, event, cx| match event { codegen::Event::Undone => { this.finish_inline_assist(inline_assist_id, false, cx) @@ -542,8 +553,8 @@ impl AssistantPanel { if let Some(language_name) = language_name { writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); } - match pending_assist.kind { - InlineAssistKind::Transform => { + match pending_assist.codegen.read(cx).kind() { + CodegenKind::Transform { .. } => { writeln!( prompt, "You're currently working inside an editor on this file:" @@ -583,7 +594,7 @@ impl AssistantPanel { ) .unwrap(); } - InlineAssistKind::Generate => { + CodegenKind::Generate { .. } => { writeln!( prompt, "You're currently working inside an editor on this file:" @@ -2649,12 +2660,6 @@ enum InlineAssistantEvent { }, } -#[derive(Copy, Clone)] -enum InlineAssistKind { - Transform, - Generate, -} - struct InlineAssistant { id: usize, prompt_editor: ViewHandle, @@ -2769,7 +2774,6 @@ impl View for InlineAssistant { impl InlineAssistant { fn new( id: usize, - kind: InlineAssistKind, measurements: Rc>, include_conversation: bool, prompt_history: VecDeque, @@ -2781,9 +2785,9 @@ impl InlineAssistant { Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), cx, ); - let placeholder = match kind { - InlineAssistKind::Transform => "Enter transformation prompt…", - InlineAssistKind::Generate => "Enter generation prompt…", + let placeholder = match codegen.read(cx).kind() { + CodegenKind::Transform { .. } => "Enter transformation prompt…", + CodegenKind::Generate { .. } => "Enter generation prompt…", }; editor.set_placeholder_text(placeholder, cx); editor @@ -2929,7 +2933,6 @@ struct BlockMeasurements { } struct PendingInlineAssist { - kind: InlineAssistKind, editor: WeakViewHandle, inline_assistant: Option<(BlockId, ViewHandle)>, codegen: ModelHandle, diff --git a/crates/ai/src/codegen.rs b/crates/ai/src/codegen.rs index 9657d9a492..bc13e294fa 100644 --- a/crates/ai/src/codegen.rs +++ b/crates/ai/src/codegen.rs @@ -4,12 +4,14 @@ use crate::{ OpenAIRequest, }; use anyhow::Result; -use editor::{multi_buffer, Anchor, MultiBuffer, ToOffset, ToPoint}; +use editor::{ + multi_buffer, Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint, +}; use futures::{ channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, SinkExt, Stream, StreamExt, }; use gpui::{executor::Background, Entity, ModelContext, ModelHandle, Task}; -use language::{IndentSize, Point, Rope, TransactionId}; +use language::{Rope, TransactionId}; use std::{cmp, future, ops::Range, sync::Arc}; pub trait CompletionProvider { @@ -57,10 +59,17 @@ pub enum Event { Undone, } +#[derive(Clone)] +pub enum CodegenKind { + Transform { range: Range }, + Generate { position: Anchor }, +} + pub struct Codegen { provider: Arc, buffer: ModelHandle, - range: Range, + snapshot: MultiBufferSnapshot, + kind: CodegenKind, last_equal_ranges: Vec>, transaction_id: Option, error: Option, @@ -76,14 +85,31 @@ impl Entity for Codegen { impl Codegen { pub fn new( buffer: ModelHandle, - range: Range, + mut kind: CodegenKind, provider: Arc, cx: &mut ModelContext, ) -> Self { + let snapshot = buffer.read(cx).snapshot(cx); + match &mut kind { + CodegenKind::Transform { range } => { + let mut point_range = range.to_point(&snapshot); + point_range.start.column = 0; + if point_range.end.column > 0 || point_range.start.row == point_range.end.row { + point_range.end.column = snapshot.line_len(point_range.end.row); + } + range.start = snapshot.anchor_before(point_range.start); + range.end = snapshot.anchor_after(point_range.end); + } + CodegenKind::Generate { position } => { + *position = position.bias_right(&snapshot); + } + } + Self { provider, buffer: buffer.clone(), - range, + snapshot, + kind, last_equal_ranges: Default::default(), transaction_id: Default::default(), error: Default::default(), @@ -109,7 +135,14 @@ impl Codegen { } pub fn range(&self) -> Range { - self.range.clone() + match &self.kind { + CodegenKind::Transform { range } => range.clone(), + CodegenKind::Generate { position } => position.bias_left(&self.snapshot)..*position, + } + } + + pub fn kind(&self) -> &CodegenKind { + &self.kind } pub fn last_equal_ranges(&self) -> &[Range] { @@ -125,56 +158,18 @@ impl Codegen { } pub fn start(&mut self, prompt: OpenAIRequest, cx: &mut ModelContext) { - let range = self.range.clone(); - let snapshot = self.buffer.read(cx).snapshot(cx); + let range = self.range(); + let snapshot = self.snapshot.clone(); let selected_text = snapshot .text_for_range(range.start..range.end) .collect::(); let selection_start = range.start.to_point(&snapshot); - let selection_end = range.end.to_point(&snapshot); - - let mut base_indent: Option = None; - let mut start_row = selection_start.row; - if snapshot.is_line_blank(start_row) { - if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { - start_row = prev_non_blank_row; - } - } - for row in start_row..=selection_end.row { - if snapshot.is_line_blank(row) { - continue; - } - - let line_indent = snapshot.indent_size_for_line(row); - if let Some(base_indent) = base_indent.as_mut() { - if line_indent.len < base_indent.len { - *base_indent = line_indent; - } - } else { - base_indent = Some(line_indent); - } - } - - let mut normalized_selected_text = selected_text.clone(); - if let Some(base_indent) = base_indent { - for row in selection_start.row..=selection_end.row { - let selection_row = row - selection_start.row; - let line_start = - normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); - let indent_len = if row == selection_start.row { - base_indent.len.saturating_sub(selection_start.column) - } else { - let line_len = normalized_selected_text.line_len(selection_row); - cmp::min(line_len, base_indent.len) - }; - let indent_end = cmp::min( - line_start + indent_len as usize, - normalized_selected_text.len(), - ); - normalized_selected_text.replace(line_start..indent_end, ""); - } - } + let suggested_line_indent = snapshot + .suggested_indents(selection_start.row..selection_start.row + 1, cx) + .into_values() + .next() + .unwrap_or_else(|| snapshot.indent_size_for_line(selection_start.row)); let response = self.provider.complete(prompt); self.generation = cx.spawn_weak(|this, mut cx| { @@ -188,66 +183,58 @@ impl Codegen { futures::pin_mut!(chunks); let mut diff = StreamingDiff::new(selected_text.to_string()); - let mut indent_len; - let indent_text; - if let Some(base_indent) = base_indent { - indent_len = base_indent.len; - indent_text = match base_indent.kind { - language::IndentKind::Space => " ", - language::IndentKind::Tab => "\t", - }; - } else { - indent_len = 0; - indent_text = ""; - }; - - let mut first_line_len = 0; - let mut first_line_non_whitespace_char_ix = None; - let mut first_line = true; let mut new_text = String::new(); + let mut base_indent = None; + let mut line_indent = None; + let mut first_line = true; while let Some(chunk) = chunks.next().await { let chunk = chunk?; - let mut lines = chunk.split('\n'); - if let Some(mut line) = lines.next() { - if first_line { - if first_line_non_whitespace_char_ix.is_none() { - if let Some(mut char_ix) = - line.find(|ch: char| !ch.is_whitespace()) - { - line = &line[char_ix..]; - char_ix += first_line_len; - first_line_non_whitespace_char_ix = Some(char_ix); - let first_line_indent = char_ix - .saturating_sub(selection_start.column as usize) - as usize; - new_text - .push_str(&indent_text.repeat(first_line_indent)); - indent_len = indent_len.saturating_sub(char_ix as u32); - } - } - first_line_len += line.len(); - } - - if first_line_non_whitespace_char_ix.is_some() { - new_text.push_str(line); - } - } - - for line in lines { - first_line = false; - new_text.push('\n'); - if !line.is_empty() { - new_text.push_str(&indent_text.repeat(indent_len as usize)); - } + let mut lines = chunk.split('\n').peekable(); + while let Some(line) = lines.next() { new_text.push_str(line); - } + if line_indent.is_none() { + if let Some(non_whitespace_ch_ix) = + new_text.find(|ch: char| !ch.is_whitespace()) + { + line_indent = Some(non_whitespace_ch_ix); + base_indent = base_indent.or(line_indent); - let hunks = diff.push_new(&new_text); - hunks_tx.send(hunks).await?; - new_text.clear(); + let line_indent = line_indent.unwrap(); + let base_indent = base_indent.unwrap(); + let indent_delta = line_indent as i32 - base_indent as i32; + let mut corrected_indent_len = cmp::max( + 0, + suggested_line_indent.len as i32 + indent_delta, + ) + as usize; + if first_line { + corrected_indent_len = corrected_indent_len + .saturating_sub(selection_start.column as usize); + } + + let indent_char = suggested_line_indent.char(); + let mut indent_buffer = [0; 4]; + let indent_str = + indent_char.encode_utf8(&mut indent_buffer); + new_text.replace_range( + ..line_indent, + &indent_str.repeat(corrected_indent_len), + ); + } + } + + if lines.peek().is_some() { + hunks_tx.send(diff.push_new(&new_text)).await?; + hunks_tx.send(diff.push_new("\n")).await?; + new_text.clear(); + line_indent = None; + first_line = false; + } + } } + hunks_tx.send(diff.push_new(&new_text)).await?; hunks_tx.send(diff.finish()).await?; anyhow::Ok(()) @@ -285,7 +272,7 @@ impl Codegen { let edit_end = edit_start + len; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); - edit_start += len; + edit_start = edit_end; this.last_equal_ranges.push(edit_range); None } @@ -410,16 +397,20 @@ mod tests { use futures::stream; use gpui::{executor::Deterministic, TestAppContext}; use indoc::indoc; - use language::{tree_sitter_rust, Buffer, Language, LanguageConfig}; + use language::{language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, Point}; use parking_lot::Mutex; use rand::prelude::*; + use settings::SettingsStore; #[gpui::test(iterations = 10)] - async fn test_autoindent( + async fn test_transform_autoindent( cx: &mut TestAppContext, mut rng: StdRng, deterministic: Arc, ) { + cx.set_global(cx.read(SettingsStore::test)); + cx.update(language_settings::init); + let text = indoc! {" fn main() { let x = 0; @@ -436,15 +427,146 @@ mod tests { snapshot.anchor_before(Point::new(1, 4))..snapshot.anchor_after(Point::new(4, 4)) }); let provider = Arc::new(TestCompletionProvider::new()); - let codegen = cx.add_model(|cx| Codegen::new(buffer.clone(), range, provider.clone(), cx)); + let codegen = cx.add_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Transform { range }, + provider.clone(), + cx, + ) + }); codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx)); - let mut new_text = indoc! {" - let mut x = 0; - while x < 10 { - x += 1; - } + let mut new_text = concat!( + " let mut x = 0;\n", + " while x < 10 {\n", + " x += 1;\n", + " }", + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + provider.send_completion(chunk); + new_text = suffix; + deterministic.run_until_parked(); + } + provider.finish_completion(); + deterministic.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_past_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + deterministic: Arc, + ) { + cx.set_global(cx.read(SettingsStore::test)); + cx.update(language_settings::init); + + let text = indoc! {" + fn main() { + le + } "}; + let buffer = + cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let position = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 6)) + }); + let provider = Arc::new(TestCompletionProvider::new()); + let codegen = cx.add_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Generate { position }, + provider.clone(), + cx, + ) + }); + codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx)); + + let mut new_text = concat!( + "t mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + provider.send_completion(chunk); + new_text = suffix; + deterministic.run_until_parked(); + } + provider.finish_completion(); + deterministic.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_before_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + deterministic: Arc, + ) { + cx.set_global(cx.read(SettingsStore::test)); + cx.update(language_settings::init); + + let text = concat!( + "fn main() {\n", + " \n", + "}\n" // + ); + let buffer = + cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let position = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 2)) + }); + let provider = Arc::new(TestCompletionProvider::new()); + let codegen = cx.add_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Generate { position }, + provider.clone(), + cx, + ) + }); + codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx)); + + let mut new_text = concat!( + "let mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); while !new_text.is_empty() { let max_len = cmp::min(new_text.len(), 10); let len = rng.gen_range(1..=max_len); From 127d03516fb40a16f1cb362d9512653b57fffe67 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 13 Sep 2023 11:57:10 +0200 Subject: [PATCH 17/28] Diff lines one chunk at a time after discovering indentation --- crates/ai/src/codegen.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/codegen.rs b/crates/ai/src/codegen.rs index bc13e294fa..e7da46cdf9 100644 --- a/crates/ai/src/codegen.rs +++ b/crates/ai/src/codegen.rs @@ -225,10 +225,13 @@ impl Codegen { } } - if lines.peek().is_some() { + if line_indent.is_some() { hunks_tx.send(diff.push_new(&new_text)).await?; - hunks_tx.send(diff.push_new("\n")).await?; new_text.clear(); + } + + if lines.peek().is_some() { + hunks_tx.send(diff.push_new("\n")).await?; line_indent = None; first_line = false; } From a6cb5f99f338d278287dea11bfe7bd117a09291b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 13 Sep 2023 12:22:33 -0400 Subject: [PATCH 18/28] v0.105.x dev --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb7543491d..5d71335230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9794,7 +9794,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.104.0" +version = "0.105.0" dependencies = [ "activity_indicator", "ai", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1d014197e1..b2339f998f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.104.0" +version = "0.105.0" publish = false [lib] From c4a5caa58731c619e80fa74c10c766ef3d48e5d9 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 13 Sep 2023 10:05:13 -0700 Subject: [PATCH 19/28] Get pgadmin loading the passfile --- crates/collab/k8s/manifest.template.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 5af1aad450..2ed9333841 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -182,6 +182,8 @@ spec: - | set -e + mkdir -p /var/lib/pgadmin/storage/max_zed.dev + python3 - < Date: Wed, 13 Sep 2023 13:34:08 -0400 Subject: [PATCH 20/28] collab 0.21.0 --- Cargo.lock | 2 +- crates/collab/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d71335230..20bc1c9d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "async-trait", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 4b29a08015..792c65b075 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.20.0" +version = "0.21.0" publish = false [[bin]] From 3910efe3ab52d6e1b317849eeb5caa91162a32db Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 13 Sep 2023 11:47:20 -0700 Subject: [PATCH 21/28] Use PostgREST instead of pgAdmin Co-authored-by: Mikayla --- crates/collab/k8s/manifest.template.yml | 82 ++++--------------------- 1 file changed, 11 insertions(+), 71 deletions(-) diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 2ed9333841..d4a7a7033e 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -35,7 +35,7 @@ metadata: spec: type: LoadBalancer selector: - app: pgadmin + app: postgrest ports: - name: web protocol: TCP @@ -144,94 +144,34 @@ apiVersion: apps/v1 kind: Deployment metadata: namespace: ${ZED_KUBE_NAMESPACE} - name: pgadmin + name: postgrest spec: replicas: 1 selector: matchLabels: - app: pgadmin + app: postgrest template: metadata: labels: - app: pgadmin + app: postgrest spec: - securityContext: - runAsUser: 0 containers: - - name: pgadmin - image: "dpage/pgadmin4" + - name: postgrest + image: "postgrest/postgrest" ports: - containerPort: 8080 protocol: TCP - livenessProbe: - httpGet: - path: /misc/ping - port: 8080 - initialDelaySeconds: 30 - periodSeconds: 5 - timeoutSeconds: 5 - readinessProbe: - httpGet: - path: /misc/ping - port: 8080 - initialDelaySeconds: 1 - periodSeconds: 1 - command: ['/bin/sh', '-c'] - args: - - | - set -e - - mkdir -p /var/lib/pgadmin/storage/max_zed.dev - - python3 - < Date: Wed, 13 Sep 2023 12:32:15 -0700 Subject: [PATCH 22/28] Run postgrest as part of foreman Co-authored-by: Mikayla --- Procfile | 3 ++- crates/collab/admin_api.conf | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 crates/collab/admin_api.conf diff --git a/Procfile b/Procfile index fcc03f55dc..127fffbed1 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,4 @@ web: cd ../zed.dev && PORT=3000 npx vercel dev collab: cd crates/collab && cargo run serve -livekit: livekit-server --dev \ No newline at end of file +livekit: livekit-server --dev +postgrest: postgrest crates/collab/admin_api.conf diff --git a/crates/collab/admin_api.conf b/crates/collab/admin_api.conf new file mode 100644 index 0000000000..5d3b0e65b7 --- /dev/null +++ b/crates/collab/admin_api.conf @@ -0,0 +1,4 @@ +db-uri = "postgres://postgres@localhost/zed" +server-port = 8081 +jwt-secret = "the-postgrest-jwt-secret-for-authorization" +log-level = "info" From 4ea6d12fe2b6953c0b3a24cc241b67a57d215a08 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 13 Sep 2023 13:46:17 -0700 Subject: [PATCH 23/28] Document that PostgREST needs to be installed for running locally --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ee426a2a6..6c502ebc74 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea ``` sudo xcodebuild -license ``` - + * Install homebrew, node and rustup-init (rutup, rust, cargo, etc.) ``` /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" brew install node rustup-init rustup-init # follow the installation steps ``` - + * Install postgres and configure the database ``` brew install postgresql@15 @@ -27,11 +27,12 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea psql -c "CREATE ROLE postgres SUPERUSER LOGIN" postgres psql -U postgres -c "CREATE DATABASE zed" ``` - -* Install the `LiveKit` server and the `foreman` process supervisor: + +* Install the `LiveKit` server, the `PostgREST` API server, and the `foreman` process supervisor: ``` brew install livekit + brew install postgrest brew install foreman ``` From 18c899a0a84ee33591ea1aec827e5ce5a7960aff Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 13 Sep 2023 15:02:59 -0700 Subject: [PATCH 24/28] Remove dead code for old admin pages --- crates/collab/src/api.rs | 217 +------- crates/collab/src/db/queries.rs | 1 - crates/collab/src/db/queries/signups.rs | 349 ------------ crates/collab/src/db/queries/users.rs | 36 -- crates/collab/src/db/tests/db_tests.rs | 541 ------------------- crates/collab/src/rpc.rs | 10 +- crates/collab/src/tests/integration_tests.rs | 1 + 7 files changed, 5 insertions(+), 1150 deletions(-) delete mode 100644 crates/collab/src/db/queries/signups.rs diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 7191400f44..a84fcf328b 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -1,8 +1,7 @@ use crate::{ auth, - db::{Invite, NewSignup, NewUserParams, User, UserId, WaitlistSummary}, - rpc::{self, ResultExt}, - AppState, Error, Result, + db::{User, UserId}, + rpc, AppState, Error, Result, }; use anyhow::anyhow; use axum::{ @@ -11,7 +10,7 @@ use axum::{ http::{self, Request, StatusCode}, middleware::{self, Next}, response::IntoResponse, - routing::{get, post, put}, + routing::{get, post}, Extension, Json, Router, }; use axum_extra::response::ErasedJson; @@ -23,18 +22,9 @@ use tracing::instrument; pub fn routes(rpc_server: Arc, state: Arc) -> Router { Router::new() .route("/user", get(get_authenticated_user)) - .route("/users", get(get_users).post(create_user)) - .route("/users/:id", put(update_user).delete(destroy_user)) .route("/users/:id/access_tokens", post(create_access_token)) - .route("/users_with_no_invites", get(get_users_with_no_invites)) - .route("/invite_codes/:code", get(get_user_for_invite_code)) .route("/panic", post(trace_panic)) .route("/rpc_server_snapshot", get(get_rpc_server_snapshot)) - .route("/signups", post(create_signup)) - .route("/signups_summary", get(get_waitlist_summary)) - .route("/user_invites", post(create_invite_from_code)) - .route("/unsent_invites", get(get_unsent_invites)) - .route("/sent_invites", post(record_sent_invites)) .layer( ServiceBuilder::new() .layer(Extension(state)) @@ -104,28 +94,6 @@ async fn get_authenticated_user( return Ok(Json(AuthenticatedUserResponse { user, metrics_id })); } -#[derive(Debug, Deserialize)] -struct GetUsersQueryParams { - query: Option, - page: Option, - limit: Option, -} - -async fn get_users( - Query(params): Query, - Extension(app): Extension>, -) -> Result>> { - let limit = params.limit.unwrap_or(100); - let users = if let Some(query) = params.query { - app.db.fuzzy_search_users(&query, limit).await? - } else { - app.db - .get_all_users(params.page.unwrap_or(0), limit) - .await? - }; - Ok(Json(users)) -} - #[derive(Deserialize, Debug)] struct CreateUserParams { github_user_id: i32, @@ -145,119 +113,6 @@ struct CreateUserResponse { metrics_id: String, } -async fn create_user( - Json(params): Json, - Extension(app): Extension>, - Extension(rpc_server): Extension>, -) -> Result>> { - let user = NewUserParams { - github_login: params.github_login, - github_user_id: params.github_user_id, - invite_count: params.invite_count, - }; - - // Creating a user via the normal signup process - let result = if let Some(email_confirmation_code) = params.email_confirmation_code { - if let Some(result) = app - .db - .create_user_from_invite( - &Invite { - email_address: params.email_address, - email_confirmation_code, - }, - user, - ) - .await? - { - result - } else { - return Ok(Json(None)); - } - } - // Creating a user as an admin - else if params.admin { - app.db - .create_user(¶ms.email_address, false, user) - .await? - } else { - Err(Error::Http( - StatusCode::UNPROCESSABLE_ENTITY, - "email confirmation code is required".into(), - ))? - }; - - if let Some(inviter_id) = result.inviting_user_id { - rpc_server - .invite_code_redeemed(inviter_id, result.user_id) - .await - .trace_err(); - } - - let user = app - .db - .get_user_by_id(result.user_id) - .await? - .ok_or_else(|| anyhow!("couldn't find the user we just created"))?; - - Ok(Json(Some(CreateUserResponse { - user, - metrics_id: result.metrics_id, - signup_device_id: result.signup_device_id, - }))) -} - -#[derive(Deserialize)] -struct UpdateUserParams { - admin: Option, - invite_count: Option, -} - -async fn update_user( - Path(user_id): Path, - Json(params): Json, - Extension(app): Extension>, - Extension(rpc_server): Extension>, -) -> Result<()> { - let user_id = UserId(user_id); - - if let Some(admin) = params.admin { - app.db.set_user_is_admin(user_id, admin).await?; - } - - if let Some(invite_count) = params.invite_count { - app.db - .set_invite_count_for_user(user_id, invite_count) - .await?; - rpc_server.invite_count_updated(user_id).await.trace_err(); - } - - Ok(()) -} - -async fn destroy_user( - Path(user_id): Path, - Extension(app): Extension>, -) -> Result<()> { - app.db.destroy_user(UserId(user_id)).await?; - Ok(()) -} - -#[derive(Debug, Deserialize)] -struct GetUsersWithNoInvites { - invited_by_another_user: bool, -} - -async fn get_users_with_no_invites( - Query(params): Query, - Extension(app): Extension>, -) -> Result>> { - Ok(Json( - app.db - .get_users_with_no_invites(params.invited_by_another_user) - .await?, - )) -} - #[derive(Debug, Deserialize)] struct Panic { version: String, @@ -327,69 +182,3 @@ async fn create_access_token( encrypted_access_token, })) } - -async fn get_user_for_invite_code( - Path(code): Path, - Extension(app): Extension>, -) -> Result> { - Ok(Json(app.db.get_user_for_invite_code(&code).await?)) -} - -async fn create_signup( - Json(params): Json, - Extension(app): Extension>, -) -> Result<()> { - app.db.create_signup(¶ms).await?; - Ok(()) -} - -async fn get_waitlist_summary( - Extension(app): Extension>, -) -> Result> { - Ok(Json(app.db.get_waitlist_summary().await?)) -} - -#[derive(Deserialize)] -pub struct CreateInviteFromCodeParams { - invite_code: String, - email_address: String, - device_id: Option, - #[serde(default)] - added_to_mailing_list: bool, -} - -async fn create_invite_from_code( - Json(params): Json, - Extension(app): Extension>, -) -> Result> { - Ok(Json( - app.db - .create_invite_from_code( - ¶ms.invite_code, - ¶ms.email_address, - params.device_id.as_deref(), - params.added_to_mailing_list, - ) - .await?, - )) -} - -#[derive(Deserialize)] -pub struct GetUnsentInvitesParams { - pub count: usize, -} - -async fn get_unsent_invites( - Query(params): Query, - Extension(app): Extension>, -) -> Result>> { - Ok(Json(app.db.get_unsent_invites(params.count).await?)) -} - -async fn record_sent_invites( - Json(params): Json>, - Extension(app): Extension>, -) -> Result<()> { - app.db.record_sent_invites(¶ms).await?; - Ok(()) -} diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 09a8f073b4..d132596438 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -7,5 +7,4 @@ pub mod contacts; pub mod projects; pub mod rooms; pub mod servers; -pub mod signups; pub mod users; diff --git a/crates/collab/src/db/queries/signups.rs b/crates/collab/src/db/queries/signups.rs deleted file mode 100644 index 8cb8d866fb..0000000000 --- a/crates/collab/src/db/queries/signups.rs +++ /dev/null @@ -1,349 +0,0 @@ -use super::*; -use hyper::StatusCode; - -impl Database { - pub async fn create_invite_from_code( - &self, - code: &str, - email_address: &str, - device_id: Option<&str>, - added_to_mailing_list: bool, - ) -> Result { - self.transaction(|tx| async move { - let existing_user = user::Entity::find() - .filter(user::Column::EmailAddress.eq(email_address)) - .one(&*tx) - .await?; - - if existing_user.is_some() { - Err(anyhow!("email address is already in use"))?; - } - - let inviting_user_with_invites = match user::Entity::find() - .filter( - user::Column::InviteCode - .eq(code) - .and(user::Column::InviteCount.gt(0)), - ) - .one(&*tx) - .await? - { - Some(inviting_user) => inviting_user, - None => { - return Err(Error::Http( - StatusCode::UNAUTHORIZED, - "unable to find an invite code with invites remaining".to_string(), - ))? - } - }; - user::Entity::update_many() - .filter( - user::Column::Id - .eq(inviting_user_with_invites.id) - .and(user::Column::InviteCount.gt(0)), - ) - .col_expr( - user::Column::InviteCount, - Expr::col(user::Column::InviteCount).sub(1), - ) - .exec(&*tx) - .await?; - - let signup = signup::Entity::insert(signup::ActiveModel { - email_address: ActiveValue::set(email_address.into()), - email_confirmation_code: ActiveValue::set(random_email_confirmation_code()), - email_confirmation_sent: ActiveValue::set(false), - inviting_user_id: ActiveValue::set(Some(inviting_user_with_invites.id)), - platform_linux: ActiveValue::set(false), - platform_mac: ActiveValue::set(false), - platform_windows: ActiveValue::set(false), - platform_unknown: ActiveValue::set(true), - device_id: ActiveValue::set(device_id.map(|device_id| device_id.into())), - added_to_mailing_list: ActiveValue::set(added_to_mailing_list), - ..Default::default() - }) - .on_conflict( - OnConflict::column(signup::Column::EmailAddress) - .update_column(signup::Column::InvitingUserId) - .to_owned(), - ) - .exec_with_returning(&*tx) - .await?; - - Ok(Invite { - email_address: signup.email_address, - email_confirmation_code: signup.email_confirmation_code, - }) - }) - .await - } - - pub async fn create_user_from_invite( - &self, - invite: &Invite, - user: NewUserParams, - ) -> Result> { - self.transaction(|tx| async { - let tx = tx; - let signup = signup::Entity::find() - .filter( - signup::Column::EmailAddress - .eq(invite.email_address.as_str()) - .and( - signup::Column::EmailConfirmationCode - .eq(invite.email_confirmation_code.as_str()), - ), - ) - .one(&*tx) - .await? - .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?; - - if signup.user_id.is_some() { - return Ok(None); - } - - let user = user::Entity::insert(user::ActiveModel { - email_address: ActiveValue::set(Some(invite.email_address.clone())), - github_login: ActiveValue::set(user.github_login.clone()), - github_user_id: ActiveValue::set(Some(user.github_user_id)), - admin: ActiveValue::set(false), - invite_count: ActiveValue::set(user.invite_count), - invite_code: ActiveValue::set(Some(random_invite_code())), - metrics_id: ActiveValue::set(Uuid::new_v4()), - ..Default::default() - }) - .on_conflict( - OnConflict::column(user::Column::GithubLogin) - .update_columns([ - user::Column::EmailAddress, - user::Column::GithubUserId, - user::Column::Admin, - ]) - .to_owned(), - ) - .exec_with_returning(&*tx) - .await?; - - let mut signup = signup.into_active_model(); - signup.user_id = ActiveValue::set(Some(user.id)); - let signup = signup.update(&*tx).await?; - - if let Some(inviting_user_id) = signup.inviting_user_id { - let (user_id_a, user_id_b, a_to_b) = if inviting_user_id < user.id { - (inviting_user_id, user.id, true) - } else { - (user.id, inviting_user_id, false) - }; - - contact::Entity::insert(contact::ActiveModel { - user_id_a: ActiveValue::set(user_id_a), - user_id_b: ActiveValue::set(user_id_b), - a_to_b: ActiveValue::set(a_to_b), - should_notify: ActiveValue::set(true), - accepted: ActiveValue::set(true), - ..Default::default() - }) - .on_conflict(OnConflict::new().do_nothing().to_owned()) - .exec_without_returning(&*tx) - .await?; - } - - Ok(Some(NewUserResult { - user_id: user.id, - metrics_id: user.metrics_id.to_string(), - inviting_user_id: signup.inviting_user_id, - signup_device_id: signup.device_id, - })) - }) - .await - } - - pub async fn set_invite_count_for_user(&self, id: UserId, count: i32) -> Result<()> { - self.transaction(|tx| async move { - if count > 0 { - user::Entity::update_many() - .filter( - user::Column::Id - .eq(id) - .and(user::Column::InviteCode.is_null()), - ) - .set(user::ActiveModel { - invite_code: ActiveValue::set(Some(random_invite_code())), - ..Default::default() - }) - .exec(&*tx) - .await?; - } - - user::Entity::update_many() - .filter(user::Column::Id.eq(id)) - .set(user::ActiveModel { - invite_count: ActiveValue::set(count), - ..Default::default() - }) - .exec(&*tx) - .await?; - Ok(()) - }) - .await - } - - pub async fn get_invite_code_for_user(&self, id: UserId) -> Result> { - self.transaction(|tx| async move { - match user::Entity::find_by_id(id).one(&*tx).await? { - Some(user) if user.invite_code.is_some() => { - Ok(Some((user.invite_code.unwrap(), user.invite_count))) - } - _ => Ok(None), - } - }) - .await - } - - pub async fn get_user_for_invite_code(&self, code: &str) -> Result { - self.transaction(|tx| async move { - user::Entity::find() - .filter(user::Column::InviteCode.eq(code)) - .one(&*tx) - .await? - .ok_or_else(|| { - Error::Http( - StatusCode::NOT_FOUND, - "that invite code does not exist".to_string(), - ) - }) - }) - .await - } - - pub async fn create_signup(&self, signup: &NewSignup) -> Result<()> { - self.transaction(|tx| async move { - signup::Entity::insert(signup::ActiveModel { - email_address: ActiveValue::set(signup.email_address.clone()), - email_confirmation_code: ActiveValue::set(random_email_confirmation_code()), - email_confirmation_sent: ActiveValue::set(false), - platform_mac: ActiveValue::set(signup.platform_mac), - platform_windows: ActiveValue::set(signup.platform_windows), - platform_linux: ActiveValue::set(signup.platform_linux), - platform_unknown: ActiveValue::set(false), - editor_features: ActiveValue::set(Some(signup.editor_features.clone())), - programming_languages: ActiveValue::set(Some(signup.programming_languages.clone())), - device_id: ActiveValue::set(signup.device_id.clone()), - added_to_mailing_list: ActiveValue::set(signup.added_to_mailing_list), - ..Default::default() - }) - .on_conflict( - OnConflict::column(signup::Column::EmailAddress) - .update_columns([ - signup::Column::PlatformMac, - signup::Column::PlatformWindows, - signup::Column::PlatformLinux, - signup::Column::EditorFeatures, - signup::Column::ProgrammingLanguages, - signup::Column::DeviceId, - signup::Column::AddedToMailingList, - ]) - .to_owned(), - ) - .exec(&*tx) - .await?; - Ok(()) - }) - .await - } - - pub async fn get_signup(&self, email_address: &str) -> Result { - self.transaction(|tx| async move { - let signup = signup::Entity::find() - .filter(signup::Column::EmailAddress.eq(email_address)) - .one(&*tx) - .await? - .ok_or_else(|| { - anyhow!("signup with email address {} doesn't exist", email_address) - })?; - - Ok(signup) - }) - .await - } - - pub async fn get_waitlist_summary(&self) -> Result { - self.transaction(|tx| async move { - let query = " - SELECT - COUNT(*) as count, - COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count, - COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count, - COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count, - COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count - FROM ( - SELECT * - FROM signups - WHERE - NOT email_confirmation_sent - ) AS unsent - "; - Ok( - WaitlistSummary::find_by_statement(Statement::from_sql_and_values( - self.pool.get_database_backend(), - query.into(), - vec![], - )) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("invalid result"))?, - ) - }) - .await - } - - pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> { - let emails = invites - .iter() - .map(|s| s.email_address.as_str()) - .collect::>(); - self.transaction(|tx| async { - let tx = tx; - signup::Entity::update_many() - .filter(signup::Column::EmailAddress.is_in(emails.iter().copied())) - .set(signup::ActiveModel { - email_confirmation_sent: ActiveValue::set(true), - ..Default::default() - }) - .exec(&*tx) - .await?; - Ok(()) - }) - .await - } - - pub async fn get_unsent_invites(&self, count: usize) -> Result> { - self.transaction(|tx| async move { - Ok(signup::Entity::find() - .select_only() - .column(signup::Column::EmailAddress) - .column(signup::Column::EmailConfirmationCode) - .filter( - signup::Column::EmailConfirmationSent.eq(false).and( - signup::Column::PlatformMac - .eq(true) - .or(signup::Column::PlatformUnknown.eq(true)), - ), - ) - .order_by_asc(signup::Column::CreatedAt) - .limit(count as u64) - .into_model() - .all(&*tx) - .await?) - }) - .await - } -} - -fn random_invite_code() -> String { - nanoid::nanoid!(16) -} - -fn random_email_confirmation_code() -> String { - nanoid::nanoid!(64) -} diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index 5cb1ef6ea3..db968ba895 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/crates/collab/src/db/queries/users.rs @@ -123,27 +123,6 @@ impl Database { .await } - pub async fn get_users_with_no_invites( - &self, - invited_by_another_user: bool, - ) -> Result> { - self.transaction(|tx| async move { - Ok(user::Entity::find() - .filter( - user::Column::InviteCount - .eq(0) - .and(if invited_by_another_user { - user::Column::InviterId.is_not_null() - } else { - user::Column::InviterId.is_null() - }), - ) - .all(&*tx) - .await?) - }) - .await - } - pub async fn get_user_metrics_id(&self, id: UserId) -> Result { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryAs { @@ -163,21 +142,6 @@ impl Database { .await } - pub async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> { - self.transaction(|tx| async move { - user::Entity::update_many() - .filter(user::Column::Id.eq(id)) - .set(user::ActiveModel { - admin: ActiveValue::set(is_admin), - ..Default::default() - }) - .exec(&*tx) - .await?; - Ok(()) - }) - .await - } - pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> { self.transaction(|tx| async move { user::Entity::update_many() diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index fc31ee7c4d..0e6a0529c4 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -575,308 +575,6 @@ async fn test_fuzzy_search_users() { } } -#[gpui::test] -async fn test_invite_codes() { - let test_db = TestDb::postgres(build_background_executor()); - let db = test_db.db(); - - let NewUserResult { user_id: user1, .. } = db - .create_user( - "user1@example.com", - false, - NewUserParams { - github_login: "user1".into(), - github_user_id: 0, - invite_count: 0, - }, - ) - .await - .unwrap(); - - // Initially, user 1 has no invite code - assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); - - // Setting invite count to 0 when no code is assigned does not assign a new code - db.set_invite_count_for_user(user1, 0).await.unwrap(); - assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); - - // User 1 creates an invite code that can be used twice. - db.set_invite_count_for_user(user1, 2).await.unwrap(); - let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 2); - - // User 2 redeems the invite code and becomes a contact of user 1. - let user2_invite = db - .create_invite_from_code( - &invite_code, - "user2@example.com", - Some("user-2-device-id"), - true, - ) - .await - .unwrap(); - let NewUserResult { - user_id: user2, - inviting_user_id, - signup_device_id, - metrics_id, - } = db - .create_user_from_invite( - &user2_invite, - NewUserParams { - github_login: "user2".into(), - github_user_id: 2, - invite_count: 7, - }, - ) - .await - .unwrap() - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!(inviting_user_id, Some(user1)); - assert_eq!(signup_device_id.unwrap(), "user-2-device-id"); - assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [Contact::Accepted { - user_id: user2, - should_notify: true, - busy: false, - }] - ); - assert_eq!( - db.get_contacts(user2).await.unwrap(), - [Contact::Accepted { - user_id: user1, - should_notify: false, - busy: false, - }] - ); - assert!(db.has_contact(user1, user2).await.unwrap()); - assert!(db.has_contact(user2, user1).await.unwrap()); - assert_eq!( - db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, - 7 - ); - - // User 3 redeems the invite code and becomes a contact of user 1. - let user3_invite = db - .create_invite_from_code(&invite_code, "user3@example.com", None, true) - .await - .unwrap(); - let NewUserResult { - user_id: user3, - inviting_user_id, - signup_device_id, - .. - } = db - .create_user_from_invite( - &user3_invite, - NewUserParams { - github_login: "user-3".into(), - github_user_id: 3, - invite_count: 3, - }, - ) - .await - .unwrap() - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 0); - assert_eq!(inviting_user_id, Some(user1)); - assert!(signup_device_id.is_none()); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user2, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user3, - should_notify: true, - busy: false, - } - ] - ); - assert_eq!( - db.get_contacts(user3).await.unwrap(), - [Contact::Accepted { - user_id: user1, - should_notify: false, - busy: false, - }] - ); - assert!(db.has_contact(user1, user3).await.unwrap()); - assert!(db.has_contact(user3, user1).await.unwrap()); - assert_eq!( - db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, - 3 - ); - - // Trying to reedem the code for the third time results in an error. - db.create_invite_from_code( - &invite_code, - "user4@example.com", - Some("user-4-device-id"), - true, - ) - .await - .unwrap_err(); - - // Invite count can be updated after the code has been created. - db.set_invite_count_for_user(user1, 2).await.unwrap(); - let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 - assert_eq!(invite_count, 2); - - // User 4 can now redeem the invite code and becomes a contact of user 1. - let user4_invite = db - .create_invite_from_code( - &invite_code, - "user4@example.com", - Some("user-4-device-id"), - true, - ) - .await - .unwrap(); - let user4 = db - .create_user_from_invite( - &user4_invite, - NewUserParams { - github_login: "user-4".into(), - github_user_id: 4, - invite_count: 5, - }, - ) - .await - .unwrap() - .unwrap() - .user_id; - - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user2, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user3, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user4, - should_notify: true, - busy: false, - } - ] - ); - assert_eq!( - db.get_contacts(user4).await.unwrap(), - [Contact::Accepted { - user_id: user1, - should_notify: false, - busy: false, - }] - ); - assert!(db.has_contact(user1, user4).await.unwrap()); - assert!(db.has_contact(user4, user1).await.unwrap()); - assert_eq!( - db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, - 5 - ); - - // An existing user cannot redeem invite codes. - db.create_invite_from_code( - &invite_code, - "user2@example.com", - Some("user-2-device-id"), - true, - ) - .await - .unwrap_err(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - - // A newer user can invite an existing one via a different email address - // than the one they used to sign up. - let user5 = db - .create_user( - "user5@example.com", - false, - NewUserParams { - github_login: "user5".into(), - github_user_id: 5, - invite_count: 0, - }, - ) - .await - .unwrap() - .user_id; - db.set_invite_count_for_user(user5, 5).await.unwrap(); - let (user5_invite_code, _) = db.get_invite_code_for_user(user5).await.unwrap().unwrap(); - let user5_invite_to_user1 = db - .create_invite_from_code(&user5_invite_code, "user1@different.com", None, true) - .await - .unwrap(); - let user1_2 = db - .create_user_from_invite( - &user5_invite_to_user1, - NewUserParams { - github_login: "user1".into(), - github_user_id: 1, - invite_count: 5, - }, - ) - .await - .unwrap() - .unwrap() - .user_id; - assert_eq!(user1_2, user1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user2, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user3, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user4, - should_notify: true, - busy: false, - }, - Contact::Accepted { - user_id: user5, - should_notify: false, - busy: false, - } - ] - ); - assert_eq!( - db.get_contacts(user5).await.unwrap(), - [Contact::Accepted { - user_id: user1, - should_notify: true, - busy: false, - }] - ); - assert!(db.has_contact(user1, user5).await.unwrap()); - assert!(db.has_contact(user5, user1).await.unwrap()); -} - test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite); async fn test_channels(db: &Arc) { @@ -1329,245 +1027,6 @@ async fn test_channel_renames(db: &Arc) { assert!(bad_name_rename.is_err()) } -#[gpui::test] -async fn test_multiple_signup_overwrite() { - let test_db = TestDb::postgres(build_background_executor()); - let db = test_db.db(); - - let email_address = "user_1@example.com".to_string(); - - let initial_signup_created_at_milliseconds = 0; - - let initial_signup = NewSignup { - email_address: email_address.clone(), - platform_mac: false, - platform_linux: true, - platform_windows: false, - editor_features: vec!["speed".into()], - programming_languages: vec!["rust".into(), "c".into()], - device_id: Some(format!("device_id")), - added_to_mailing_list: false, - created_at: Some( - DateTime::from_timestamp_millis(initial_signup_created_at_milliseconds).unwrap(), - ), - }; - - db.create_signup(&initial_signup).await.unwrap(); - - let initial_signup_from_db = db.get_signup(&email_address).await.unwrap(); - - assert_eq!( - initial_signup_from_db.clone(), - signup::Model { - email_address: initial_signup.email_address, - platform_mac: initial_signup.platform_mac, - platform_linux: initial_signup.platform_linux, - platform_windows: initial_signup.platform_windows, - editor_features: Some(initial_signup.editor_features), - programming_languages: Some(initial_signup.programming_languages), - added_to_mailing_list: initial_signup.added_to_mailing_list, - ..initial_signup_from_db - } - ); - - let subsequent_signup = NewSignup { - email_address: email_address.clone(), - platform_mac: true, - platform_linux: false, - platform_windows: true, - editor_features: vec!["git integration".into(), "clean design".into()], - programming_languages: vec!["d".into(), "elm".into()], - device_id: Some(format!("different_device_id")), - added_to_mailing_list: true, - // subsequent signup happens next day - created_at: Some( - DateTime::from_timestamp_millis( - initial_signup_created_at_milliseconds + (1000 * 60 * 60 * 24), - ) - .unwrap(), - ), - }; - - db.create_signup(&subsequent_signup).await.unwrap(); - - let subsequent_signup_from_db = db.get_signup(&email_address).await.unwrap(); - - assert_eq!( - subsequent_signup_from_db.clone(), - signup::Model { - platform_mac: subsequent_signup.platform_mac, - platform_linux: subsequent_signup.platform_linux, - platform_windows: subsequent_signup.platform_windows, - editor_features: Some(subsequent_signup.editor_features), - programming_languages: Some(subsequent_signup.programming_languages), - device_id: subsequent_signup.device_id, - added_to_mailing_list: subsequent_signup.added_to_mailing_list, - // shouldn't overwrite their creation Datetime - user shouldn't lose their spot in line - created_at: initial_signup_from_db.created_at, - ..subsequent_signup_from_db - } - ); -} - -#[gpui::test] -async fn test_signups() { - let test_db = TestDb::postgres(build_background_executor()); - let db = test_db.db(); - - let usernames = (0..8).map(|i| format!("person-{i}")).collect::>(); - - let all_signups = usernames - .iter() - .enumerate() - .map(|(i, username)| NewSignup { - email_address: format!("{username}@example.com"), - platform_mac: true, - platform_linux: i % 2 == 0, - platform_windows: i % 4 == 0, - editor_features: vec!["speed".into()], - programming_languages: vec!["rust".into(), "c".into()], - device_id: Some(format!("device_id_{i}")), - added_to_mailing_list: i != 0, // One user failed to subscribe - created_at: Some(DateTime::from_timestamp_millis(i as i64).unwrap()), // Signups are consecutive - }) - .collect::>(); - - // people sign up on the waitlist - for signup in &all_signups { - // users can sign up multiple times without issues - for _ in 0..2 { - db.create_signup(&signup).await.unwrap(); - } - } - - assert_eq!( - db.get_waitlist_summary().await.unwrap(), - WaitlistSummary { - count: 8, - mac_count: 8, - linux_count: 4, - windows_count: 2, - unknown_count: 0, - } - ); - - // retrieve the next batch of signup emails to send - let signups_batch1 = db.get_unsent_invites(3).await.unwrap(); - let addresses = signups_batch1 - .iter() - .map(|s| &s.email_address) - .collect::>(); - assert_eq!( - addresses, - &[ - all_signups[0].email_address.as_str(), - all_signups[1].email_address.as_str(), - all_signups[2].email_address.as_str() - ] - ); - assert_ne!( - signups_batch1[0].email_confirmation_code, - signups_batch1[1].email_confirmation_code - ); - - // the waitlist isn't updated until we record that the emails - // were successfully sent. - let signups_batch = db.get_unsent_invites(3).await.unwrap(); - assert_eq!(signups_batch, signups_batch1); - - // once the emails go out, we can retrieve the next batch - // of signups. - db.record_sent_invites(&signups_batch1).await.unwrap(); - let signups_batch2 = db.get_unsent_invites(3).await.unwrap(); - let addresses = signups_batch2 - .iter() - .map(|s| &s.email_address) - .collect::>(); - assert_eq!( - addresses, - &[ - all_signups[3].email_address.as_str(), - all_signups[4].email_address.as_str(), - all_signups[5].email_address.as_str() - ] - ); - - // the sent invites are excluded from the summary. - assert_eq!( - db.get_waitlist_summary().await.unwrap(), - WaitlistSummary { - count: 5, - mac_count: 5, - linux_count: 2, - windows_count: 1, - unknown_count: 0, - } - ); - - // user completes the signup process by providing their - // github account. - let NewUserResult { - user_id, - inviting_user_id, - signup_device_id, - .. - } = db - .create_user_from_invite( - &Invite { - ..signups_batch1[0].clone() - }, - NewUserParams { - github_login: usernames[0].clone(), - github_user_id: 0, - invite_count: 5, - }, - ) - .await - .unwrap() - .unwrap(); - let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); - assert!(inviting_user_id.is_none()); - assert_eq!(user.github_login, usernames[0]); - assert_eq!( - user.email_address, - Some(all_signups[0].email_address.clone()) - ); - assert_eq!(user.invite_count, 5); - assert_eq!(signup_device_id.unwrap(), "device_id_0"); - - // cannot redeem the same signup again. - assert!(db - .create_user_from_invite( - &Invite { - email_address: signups_batch1[0].email_address.clone(), - email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), - }, - NewUserParams { - github_login: "some-other-github_account".into(), - github_user_id: 1, - invite_count: 5, - }, - ) - .await - .unwrap() - .is_none()); - - // cannot redeem a signup with the wrong confirmation code. - db.create_user_from_invite( - &Invite { - email_address: signups_batch1[1].email_address.clone(), - email_confirmation_code: "the-wrong-code".to_string(), - }, - NewUserParams { - github_login: usernames[1].clone(), - github_user_id: 2, - invite_count: 5, - }, - ) - .await - .unwrap_err(); -} - fn build_background_executor() -> Arc { Deterministic::new(0).build_background() } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e454fcbb9e..2d66b43b93 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -553,9 +553,8 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4( + let (contacts, channels_for_user, channel_invites) = future::try_join3( this.app_state.db.get_contacts(user_id), - this.app_state.db.get_invite_code_for_user(user_id), this.app_state.db.get_channels_for_user(user_id), this.app_state.db.get_channel_invites_for_user(user_id) ).await?; @@ -568,13 +567,6 @@ impl Server { channels_for_user, channel_invites ))?; - - if let Some((code, count)) = invite_code { - this.peer.send(connection_id, proto::UpdateInviteInfo { - url: format!("{}{}", this.app_state.config.invite_link_prefix, code), - count: count as u32, - })?; - } } if let Some(incoming_call) = this.app_state.db.incoming_call_for_user(user_id).await? { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 8121b0ac91..a9f4a31eb7 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -3146,6 +3146,7 @@ async fn test_local_settings( ) .await; let (project_a, _) = client_a.build_local_project("/dir", cx_a).await; + deterministic.run_until_parked(); let project_id = active_call_a .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .await From 15bdff1c5b05d65d93a9813d58f5ebc91fabca14 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 13 Sep 2023 20:19:14 -0400 Subject: [PATCH 25/28] Fix toggle replace tooltip --- crates/search/src/search.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 0135ed4eed..5cdeb0a494 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -110,7 +110,7 @@ fn toggle_replace_button( button_style: ToggleIconButtonStyle, ) -> AnyElement { Button::dynamic_action(Box::new(ToggleReplace)) - .with_tooltip("Toggle replace", tooltip_style) + .with_tooltip("Toggle Replace", tooltip_style) .with_contents(theme::components::svg::Svg::new("icons/replace.svg")) .toggleable(active) .with_style(button_style) From 1eb74acb3ecc474163b52b564f8071df0525a579 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 14 Sep 2023 20:24:21 +0200 Subject: [PATCH 26/28] editor: Do not run brace completion on empty text. (#2965) Users of keyboard layout with IME complained about the peculiar behaviour where typing in "sss" and then removing all of it left behind one 's' and also appended a closing brace. This was not reproducible on a buffer without language, so I've suspected that brace insertion might be a problem here. For whatever reason when the user removes the last character from a run that triggered IME, we receive a notification about an empty insertion. Sadly, brace completion does not handle an empty input properly and we erroneously insert a closing brace when deleting the followup characters. In fact, the brace inserted is always the closing brace for the first entry in language's config.toml 'brackets' field (see Scheme vs Markdown). This guard also allows for the proper removal of the first character. Closes community tickets zed-industries/community#877 zed-industries/community#1329 Z-2869 Release Notes: - Fixed handling of bracket completion for international keyboard layouts that use IME. This led to Zed erroneously inserting the `}` character while removing the first character that triggered IME. --- crates/editor/src/editor.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d651980f33..3f94f815f2 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2289,14 +2289,18 @@ impl Editor { // bracket of any of this language's bracket pairs. let mut bracket_pair = None; let mut is_bracket_pair_start = false; - for (pair, enabled) in scope.brackets() { - if enabled && pair.close && pair.start.ends_with(text.as_ref()) { - bracket_pair = Some(pair.clone()); - is_bracket_pair_start = true; - break; - } else if pair.end.as_str() == text.as_ref() { - bracket_pair = Some(pair.clone()); - break; + if !text.is_empty() { + // `text` can be empty when an user is using IME (e.g. Chinese Wubi Simplified) + // and they are removing the character that triggered IME popup. + for (pair, enabled) in scope.brackets() { + if enabled && pair.close && pair.start.ends_with(text.as_ref()) { + bracket_pair = Some(pair.clone()); + is_bracket_pair_start = true; + break; + } else if pair.end.as_str() == text.as_ref() { + bracket_pair = Some(pair.clone()); + break; + } } } From 8ff3e370446f786554652741cf9e9e6e8da21315 Mon Sep 17 00:00:00 2001 From: KCaverly Date: Thu, 14 Sep 2023 15:42:21 -0400 Subject: [PATCH 27/28] small fix to rate status update --- crates/search/src/project_search.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 6ca4928803..cdf7bd4a51 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -701,8 +701,9 @@ impl ProjectSearchView { })); return; } + } else { + semantic_state.maintain_rate_limit = None; } - semantic_state.maintain_rate_limit = None; } } From a1353b8bb9b26263a133fdea2359fcc597492623 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 14 Sep 2023 23:25:27 +0200 Subject: [PATCH 28/28] search_bar: Add toggle_replace_on_a_pane. (#2966) This allows users to add a keybind to ToggleReplace from Editor/Pane contexts. Release Notes: - Fixed replace in buffer not reacting to keyboard shortcuts outside of search bar. --- crates/search/src/buffer_search.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 9ae2b20f7a..7ba67bb649 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -56,6 +56,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(BufferSearchBar::replace_all_on_pane); cx.add_action(BufferSearchBar::replace_next_on_pane); cx.add_action(BufferSearchBar::toggle_replace); + cx.add_action(BufferSearchBar::toggle_replace_on_a_pane); add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); } @@ -889,6 +890,21 @@ impl BufferSearchBar { cx.notify(); } } + fn toggle_replace_on_a_pane(pane: &mut Pane, _: &ToggleReplace, cx: &mut ViewContext) { + let mut should_propagate = true; + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |bar, cx| { + if let Some(_) = &bar.active_searchable_item { + should_propagate = false; + bar.replace_is_active = !bar.replace_is_active; + cx.notify(); + } + }); + } + if should_propagate { + cx.propagate_action(); + } + } fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { if !self.dismissed && self.active_search.is_some() { if let Some(searchable_item) = self.active_searchable_item.as_ref() { @@ -934,12 +950,16 @@ impl BufferSearchBar { fn replace_next_on_pane(pane: &mut Pane, action: &ReplaceNext, cx: &mut ViewContext) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |bar, cx| bar.replace_next(action, cx)); + return; } + cx.propagate_action(); } fn replace_all_on_pane(pane: &mut Pane, action: &ReplaceAll, cx: &mut ViewContext) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |bar, cx| bar.replace_all(action, cx)); + return; } + cx.propagate_action(); } }