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 | 553 ++++++------------------------ crates/ai/src/codegen.rs | 468 +++++++++++++++++++++++++ crates/editor/src/editor.rs | 10 +- crates/editor/src/multi_buffer.rs | 43 ++- 5 files changed, 607 insertions(+), 468 deletions(-) create mode 100644 crates/ai/src/codegen.rs diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 2c2d7e774e120980d212cdcbd887289ebee0e768..2e8eca80e3c2dbf1fe99e3849c6eae26917f4440 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 9b384252fc0dfea3fe7897c1152fbca18fbcd9e0..1d56a6308cfeab744a092968f5f9bc2d5470efb6 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,44 +332,53 @@ 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 0000000000000000000000000000000000000000..b24c0f94356111e5d045a58394696bca902efcd8 --- /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 bdd29b04fa20e2ecd2492d6554f5541b2bc99540..12df29df1d4ea88d584f9e25d9a0df423b151606 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 74283fd778dda2a4d1f0125338eade33d89216cf..c5d17dfd2eb8ac67f81a19f63ccbb7f32f9c1c62 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); - } + if let Some(transaction_id) = transaction_id { + cx.emit(Event::TransactionUndone { transaction_id }); } - None + 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 | 113 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 121e9a28dd160e9eef6d4d7497c7876196a57c88..bf0ed9b163253cca33a4b857e4df502ba0de18f6 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 4438f88108988e715d476a65fc2566d8a5f8e090..d96e470d5cdce93923e156ca9afb4e32a6f921e1 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 2e8eca80e3c2dbf1fe99e3849c6eae26917f4440..7d9b93b0a7dbdcd41954cecc40c62fee14689a13 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 b24c0f94356111e5d045a58394696bca902efcd8..9657d9a4926c17eadb5ae7d78a020ee6342deacf 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 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(); - use super::*; + 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 b47907783ea1d8397b84a5bbc47b8924a19c285f..62b6a49b901d229f22bc62acac64bd14d7b712c1 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 c232ff98493ece4890cd09d84a1aef34d4670e08..5179b56b1f2a0fe8b93cad21dc7d06ae6c3a4904 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 62b6a49b901d229f22bc62acac64bd14d7b712c1..bbc0a51b28109174d84a937c2397ea54f0c6aed1 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 5179b56b1f2a0fe8b93cad21dc7d06ae6c3a4904..6821ec64e5ebc988dc1678126a1a3671aeb6ba9d 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 d920abee9017b46b8ecf9cf38047bbf0f06c64f9..3ef3f9ddd327e9d29ab9049cfd27301de7e1845c 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 12fd8dbd2b66df8ef94ea61e9f80718769c6a28c..34b81fbb4c7419bd6059b294ea87edb4168331da 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 ae85acaab55a5344281ebf8ad268f9ddc54b1f27..1126eb11551353a5e05c79139fd02b9f18d78f4f 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 1a7c789aad9a680eddef208eed182b5b237ca286..28f9e3c2a43359258856b1ea80d0373dd566f6b3 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 1b3dcee6adc4827d42bd6d71c298d49fa991fda9..6319ba1663eddd79ed92de4ea056d750df35c90b 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 4ca0c42909d7542a1f454247ad54ae8f58f98acc..b488a879f2fade76a2da98defee03291b049ac88 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 d0dbb9e3068c9de28b2fe90258ef22112f9c8bb3..26aff7baa420a37b40f71a0e01b1ba440326bd1f 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 7359178f0eba91b06780301f4ddc6b00c03b97e5..8fd4049767bfada65bbfd57142eb96c43c310366 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 03a74d46ce07f76ade67875d578d7f1551a30563..74363bc7b77e844ccea6936182a3f4e7b8c82ed1 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 0000000000000000000000000000000000000000..de19c5d29d17cbb13a7a1db58c526e6534dee751 --- /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 0000000000000000000000000000000000000000..c39b8b09c098c5c5299ea922d459091bafff5794 --- /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 | 271 +++++++++++------- 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, 340 insertions(+), 129 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 fa62a74f3f7e27485cb0f36abd4c5ab5ab82ea38..2fb1c6f5fcaa8baf4c3a128644b372408260c501 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 bbc0a51b28109174d84a937c2397ea54f0c6aed1..1a7b81ee8f5cb7e16e1290b1e7141d834ee6a887 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 9141a02ab3550c29262f235348e9beadfde15d9e..7495b302a29db477521fab56ddd8ee237bc0ed21 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 3ef3f9ddd327e9d29ab9049cfd27301de7e1845c..16a4150dabafd578eb4347d38956bef56147290a 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 28f9e3c2a43359258856b1ea80d0373dd566f6b3..6954ace71fed459ec541e3ddf30e2ea14caed4af 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(); - - 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; - } + cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false)); +} - 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::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::VisualLine { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualLine, false, 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::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::VisualLine { rows } => { - visual_motion( - Motion::Down { - display_lines: false, - }, - Some(rows as usize), - cx, - ); + } + + 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; + }; + + 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(); } - 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(()) - } + 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); - }); + } + 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 74363bc7b77e844ccea6936182a3f4e7b8c82ed1..fea6f26ef1816defa70cd51363f46ebba021831f 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 0000000000000000000000000000000000000000..470888cf6e94b78351a9479401b1b8d121a73510 --- /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 0000000000000000000000000000000000000000..ac6637633c384a3d5e129ef59bd82a2775f87342 --- /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 9aa9fffc0a76905e65ab49d6fda6a85c64dad484..70a0b7c7a64a535b3e63e3e011c1c16ffc5336f0 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 fea6f26ef1816defa70cd51363f46ebba021831f..c6488fe171c5d352750d7e8fc4f380134ab199b7 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 0000000000000000000000000000000000000000..8bc78de7d367678b32ec2a19ef9d5109a8708854 --- /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 da5c7d46eda3813c784eeff238c965528fb3cca3..ae6a2808cffdc9def7deda372b931b02ec3e2bad 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 6954ace71fed459ec541e3ddf30e2ea14caed4af..d7d8c3077382c281e729a9ccb686074db4cc420d 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 7495b302a29db477521fab56ddd8ee237bc0ed21..fb567fab6a7aecc66931be5edca2ff0300536cac 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 6821ec64e5ebc988dc1678126a1a3671aeb6ba9d..3e65e6d504c6a3de90fcc06f84155315f4025eb8 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 16a4150dabafd578eb4347d38956bef56147290a..20344366944b382c42034ce39285f00fb78e6a31 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 34b81fbb4c7419bd6059b294ea87edb4168331da..22d09f8359f52060a7435cef321ca0c7f71bd37c 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 d7d8c3077382c281e729a9ccb686074db4cc420d..df9e9a32ad7d5c602526801ca857feb3c1226da7 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 6319ba1663eddd79ed92de4ea056d750df35c90b..877fff328bb1fdb1f4fc83e01258daf3a8ac28b7 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 b488a879f2fade76a2da98defee03291b049ac88..d0c8a56249447100409bfd5cf385b2278752ca3d 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 26aff7baa420a37b40f71a0e01b1ba440326bd1f..bb6e1abf92a0a687e32c7ee6a1e92787a1a82ba9 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 8fd4049767bfada65bbfd57142eb96c43c310366..2cb5e058e8726e88a8282b4969b8f3b2b659b6ed 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 70a0b7c7a64a535b3e63e3e011c1c16ffc5336f0..a708d3c12adece2d758e95ffd762f86bae5b5fc8 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 b433a6bfc05ea61b0758bcb8ac81ba189c57573c..97df989e4d57e6123ff84e39d7a8427134b40533 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 c6488fe171c5d352750d7e8fc4f380134ab199b7..79117177655d6a6baa8163168e8c35f40d7409c8 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 8bc78de7d367678b32ec2a19ef9d5109a8708854..6ef6b3601786f21a340367cfeef38b1bf8555cbb 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 f1a1a3c138509420d6e0e92daf679fb347a6e673..331ef52ecb96174e6669b3a4665b39b476e88972 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 0000000000000000000000000000000000000000..bc1253deb580ab8218bf75dd36a7e5c3f63d613c --- /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 836ce1492b6fa128ee88a232bcf816accc790e7c..e9f3001392c9a7571c211d298dd171e2aa5ae411 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 1126eb11551353a5e05c79139fd02b9f18d78f4f..19ea6af875f6508ccac20447ad9d4d102a238882 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 97df989e4d57e6123ff84e39d7a8427134b40533..0df5c32136b2818bba631f8d2c64c104a274944f 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 2193b5c07ed276f4b4f2030a94ffa95d70cca6a8..07bea434e0dffd8f93146720966212a176e87d19 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 20344366944b382c42034ce39285f00fb78e6a31..c8d12f8ee33047c2148710b79360735d8ae9c114 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 19ea6af875f6508ccac20447ad9d4d102a238882..848e9f725d815f2b7b327e3757eb1b9445e65636 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 0df5c32136b2818bba631f8d2c64c104a274944f..e58f805a026f32473ab18b4dccc9eb662ae6e541 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 89b4469d42d0f795f27db338cb2e84eb224431d8..c3733018b67e0142115c3baac2d8a068d5f6e328 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 fa62a74f3f7e27485cb0f36abd4c5ab5ab82ea38..dd5544f03483bb8f1551df9d3d06862dbf4c8edf 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 6a227812d17ef08bfe9f1153e378e34df333b4c7..9ae2b20f7af49626732697f7fdf418a9b4764fa7 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 bc95b91819e875d757a5736dbd6fda6b8011aa49..b0ac023c09c90b35804ad6544df83195c83498f1 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 79dd2b885104bac14afcdfbb3bb40e9d65d40b8c..8f6915019bc8ceb4e1743188afb536ec6671b6f5 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 f675da6a99ecb7441f7cccff0242077b4b023da1..efccb18506de28beb41c2517453259ee627ca8ea 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 8f6915019bc8ceb4e1743188afb536ec6671b6f5..5af1aad450f9cc111fbb4f4130625293721efcec 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 | 338 +++++++++++++++++++++++++------------ 2 files changed, 257 insertions(+), 132 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1d56a6308cfeab744a092968f5f9bc2d5470efb6..6d4fce2f6d09de085f2e78e8f10aeaad8c0bd16c 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 9657d9a4926c17eadb5ae7d78a020ee6342deacf..bc13e294fa8ca9a6e49c811a789ce663d4e1e37e 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); + 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 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); } - } - first_line_len += line.len(); - } - if first_line_non_whitespace_char_ix.is_some() { - new_text.push_str(line); + 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), + ); + } } - } - 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)); + 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; } - new_text.push_str(line); } - - let hunks = diff.push_new(&new_text); - hunks_tx.send(hunks).await?; - new_text.clear(); } + 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 bc13e294fa8ca9a6e49c811a789ce663d4e1e37e..e7da46cdf95f50927f0f9350f45dfb361bdfbd2e 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 eb7543491dbec35c627d90151ea52fbcdda21dd8..5d713352301339878445414f55a6412856da8a0f 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 1d014197e1e14262180763151c2649dd04158686..b2339f998f292161eedeaf4a5ef5f2365d9bee3f 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 5af1aad450f9cc111fbb4f4130625293721efcec..2ed9333841e793944e567d0b995e5e84bd462e9e 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 5d713352301339878445414f55a6412856da8a0f..20bc1c9d0da2ca4fc1824f8f48a913b43ca62229 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 4b29a0801551fae8496eeaa86c55bfb303a8aeb0..792c65b075fca464f502ee1571e775c7aee623ec 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 2ed9333841e793944e567d0b995e5e84bd462e9e..d4a7a7033e8427ca87d03b4055e86b28b425dbe0 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 fcc03f55dc2add371dd02b7b99629eacbce9bddb..127fffbed1f571986fd496e867b5b3fa82f97262 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 0000000000000000000000000000000000000000..5d3b0e65b738ed2291c782f62d7e45a8b43c9895 --- /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 2ee426a2a6777c7fc7a0f1b1784ee47aa98ce014..6c502ebc74fadd46df3546c85e0dc25fbb3ca806 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 7191400f4488dd221e175d8e099292dfb3717bcf..a84fcf328ba4e92214074b55fc0d849e5b69db61 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 09a8f073b469f72773a0220750f5d65cf85629af..d13259643893dbdc8ccad6f5b34dfb22b6676db0 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 8cb8d866fb401cf20f6b29aac1deac84ea584ec7..0000000000000000000000000000000000000000 --- 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 5cb1ef6ea39c6dbca8bb58131f36428580a0aa9d..db968ba8958dd3685e629f32bf8d5076dfd0eef1 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 fc31ee7c4d4aee8dddc46bf6cc0e77fc89e4dd39..0e6a0529c4f72636069d5e1dab43db05d0f4de9c 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 e454fcbb9e7a4f2202602ca1ef7947ea6d6b6c9b..2d66b43b93e07aeb63c778af668628907afd21f4 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 8121b0ac91d5021e2830236c1694a24a20cff3b5..a9f4a31eb7ca17c9669b15387005efb2bf8cded0 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 0135ed4eed69a6b0b1d5ad2f73a7521120c4cc8b..5cdeb0a494dc234494542380197eefa837ad9327 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 d651980f331f0e8f08e2d0d3bddc52d1c0f7fc7e..3f94f815f2f346916c3b215acead0d71469e6bcc 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 6ca492880385437d8cc254dd1dd702c1d639f8e6..cdf7bd4a51c82a1a438e2142174d0cb8180bb4e5 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 9ae2b20f7af49626732697f7fdf418a9b4764fa7..7ba67bb6497923a7896cd064334942de180ca8d0 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(); } }