Merge branch 'main' into chat-again

This commit is contained in:
Max Brunsfeld 2023-09-14 14:50:45 -07:00
commit 9d8c6a7ed1
51 changed files with 1854 additions and 2023 deletions

5
Cargo.lock generated
View file

@ -114,6 +114,7 @@ dependencies = [
"log",
"menu",
"ordered-float",
"parking_lot 0.11.2",
"project",
"rand 0.8.5",
"regex",
@ -1453,7 +1454,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"async-trait",
@ -9794,7 +9795,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.104.0"
version = "0.105.0"
dependencies = [
"activity_indicator",
"ai",

View file

@ -1,3 +1,4 @@
web: cd ../zed.dev && PORT=3000 npx vercel dev
collab: cd crates/collab && cargo run serve
livekit: livekit-server --dev
livekit: livekit-server --dev
postgrest: postgrest crates/collab/admin_api.conf

View file

@ -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
```

View file

@ -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"
@ -533,7 +540,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"
}
},
{

View file

@ -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",
@ -326,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": [
@ -389,7 +391,7 @@
}
},
{
"context": "Editor && vim_operator == n",
"context": "Editor && VimCount",
"bindings": {
"0": [
"vim::Number",
@ -497,7 +499,7 @@
"around": true
}
}
],
]
}
},
{

View file

@ -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

View file

@ -1,5 +1,6 @@
pub mod assistant;
mod assistant_settings;
mod codegen;
mod streaming_diff;
use anyhow::{anyhow, Result};
@ -26,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<RequestMessage>,

View file

@ -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, CodegenKind, 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,23 +262,40 @@ impl AssistantPanel {
}
fn new_inline_assist(&mut self, editor: &ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
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 provider = Arc::new(OpenAICompletionProvider::new(
api_key,
cx.background().clone(),
));
let selection = editor.read(cx).selections.newest_anchor().clone();
let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot);
let assist_kind = if editor.read(cx).selections.newest::<usize>(cx).is_empty() {
InlineAssistKind::Generate
let codegen_kind = if editor.read(cx).selections.newest::<usize>(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(),
codegen.clone(),
cx,
);
cx.focus_self();
@ -321,48 +334,64 @@ impl AssistantPanel {
self.pending_inline_assists.insert(
inline_assist_id,
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.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)
}
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 +417,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 +446,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 +461,7 @@ impl AssistantPanel {
cx.propagate_action();
}
fn close_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext<Self>) {
fn finish_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext<Self>) {
self.hide_inline_assist(assist_id, cx);
if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) {
@ -450,13 +479,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 +506,6 @@ impl AssistantPanel {
include_conversation: bool,
cx: &mut ViewContext<Self>,
) {
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 +533,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::<Rope>();
let selection_start = range.start.to_point(&snapshot);
let selection_end = range.end.to_point(&snapshot);
let mut base_indent: Option<language::IndentSize> = 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::<String>();
let language = snapshot.language_at(range.start);
let language_name = if let Some(language) = language.as_ref() {
@ -581,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:"
@ -608,7 +580,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!(
@ -622,7 +594,7 @@ impl AssistantPanel {
)
.unwrap();
}
InlineAssistKind::Generate => {
CodegenKind::Generate { .. } => {
writeln!(
prompt,
"You're currently working inside an editor on this file:"
@ -689,209 +661,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 +681,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());
}
}
@ -2887,12 +2660,6 @@ enum InlineAssistantEvent {
},
}
#[derive(Copy, Clone)]
enum InlineAssistKind {
Transform,
Generate,
}
struct InlineAssistant {
id: usize,
prompt_editor: ViewHandle<Editor>,
@ -2900,11 +2667,11 @@ struct InlineAssistant {
has_focus: bool,
include_conversation: bool,
measurements: Rc<Cell<BlockMeasurements>>,
error: Option<anyhow::Error>,
prompt_history: VecDeque<String>,
prompt_history_ix: Option<usize>,
pending_prompt: String,
_subscription: Subscription,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
}
impl Entity for InlineAssistant {
@ -2933,7 +2700,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)
@ -3007,10 +2774,10 @@ impl View for InlineAssistant {
impl InlineAssistant {
fn new(
id: usize,
kind: InlineAssistKind,
measurements: Rc<Cell<BlockMeasurements>>,
include_conversation: bool,
prompt_history: VecDeque<String>,
codegen: ModelHandle<Codegen>,
cx: &mut ViewContext<Self>,
) -> Self {
let prompt_editor = cx.add_view(|cx| {
@ -3018,14 +2785,17 @@ 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
});
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 +2803,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 +2823,31 @@ impl InlineAssistant {
}
}
fn handle_codegen_changed(&mut self, _: ModelHandle<Codegen>, cx: &mut ViewContext<Self>) {
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<Self>) {
cx.emit(InlineAssistantEvent::Canceled);
}
@ -3076,7 +2871,6 @@ impl InlineAssistant {
include_conversation: self.include_conversation,
});
self.confirmed = true;
self.error = None;
cx.notify();
}
}
@ -3093,19 +2887,6 @@ impl InlineAssistant {
cx.notify();
}
fn set_error(&mut self, error: anyhow::Error, cx: &mut ViewContext<Self>) {
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<Self>) {
if let Some(ix) = self.prompt_history_ix {
if ix > 0 {
@ -3152,13 +2933,9 @@ struct BlockMeasurements {
}
struct PendingInlineAssist {
kind: InlineAssistKind,
editor: WeakViewHandle<Editor>,
range: Range<Anchor>,
highlighted_ranges: Vec<Range<Anchor>>,
inline_assistant: Option<(BlockId, ViewHandle<InlineAssistant>)>,
code_generation: Task<Option<()>>,
transaction_id: Option<TransactionId>,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
}
@ -3184,65 +2961,10 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
}
}
fn strip_markdown_codeblock(
stream: impl Stream<Item = Result<String>>,
) -> impl Stream<Item = Result<String>> {
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 +3333,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::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"```js\nLorem ipsum dolor\n```"
);
assert_eq!(
strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"``\nLorem ipsum dolor\n```"
);
fn chunks(text: &str, size: usize) -> impl Stream<Item = Result<String>> {
stream::iter(
text.chars()
.collect::<Vec<_>>()
.chunks(size)
.map(|chunk| Ok(chunk.iter().collect::<String>()))
.collect::<Vec<_>>(),
)
}
}
fn messages(
conversation: &ModelHandle<Conversation>,
cx: &AppContext,

704
crates/ai/src/codegen.rs Normal file
View file

@ -0,0 +1,704 @@
use crate::{
stream_completion,
streaming_diff::{Hunk, StreamingDiff},
OpenAIRequest,
};
use anyhow::Result;
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::{Rope, TransactionId};
use std::{cmp, future, ops::Range, sync::Arc};
pub trait CompletionProvider {
fn complete(
&self,
prompt: OpenAIRequest,
) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>>;
}
pub struct OpenAICompletionProvider {
api_key: String,
executor: Arc<Background>,
}
impl OpenAICompletionProvider {
pub fn new(api_key: String, executor: Arc<Background>) -> Self {
Self { api_key, executor }
}
}
impl CompletionProvider for OpenAICompletionProvider {
fn complete(
&self,
prompt: OpenAIRequest,
) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
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,
}
#[derive(Clone)]
pub enum CodegenKind {
Transform { range: Range<Anchor> },
Generate { position: Anchor },
}
pub struct Codegen {
provider: Arc<dyn CompletionProvider>,
buffer: ModelHandle<MultiBuffer>,
snapshot: MultiBufferSnapshot,
kind: CodegenKind,
last_equal_ranges: Vec<Range<Anchor>>,
transaction_id: Option<TransactionId>,
error: Option<anyhow::Error>,
generation: Task<()>,
idle: bool,
_subscription: gpui::Subscription,
}
impl Entity for Codegen {
type Event = Event;
}
impl Codegen {
pub fn new(
buffer: ModelHandle<MultiBuffer>,
mut kind: CodegenKind,
provider: Arc<dyn CompletionProvider>,
cx: &mut ModelContext<Self>,
) -> 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(),
snapshot,
kind,
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<MultiBuffer>,
event: &multi_buffer::Event,
cx: &mut ModelContext<Self>,
) {
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<Anchor> {
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<Anchor>] {
&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<Self>) {
let range = self.range();
let snapshot = self.snapshot.clone();
let selected_text = snapshot
.text_for_range(range.start..range.end)
.collect::<Rope>();
let selection_start = range.start.to_point(&snapshot);
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| {
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 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').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);
}
let indent_char = suggested_line_indent.char();
let mut indent_buffer = [0; 4];
let indent_str =
indent_char.encode_utf8(&mut indent_buffer);
new_text.replace_range(
..line_indent,
&indent_str.repeat(corrected_indent_len),
);
}
}
if line_indent.is_some() {
hunks_tx.send(diff.push_new(&new_text)).await?;
new_text.clear();
}
if lines.peek().is_some() {
hunks_tx.send(diff.push_new("\n")).await?;
line_indent = None;
first_line = false;
}
}
}
hunks_tx.send(diff.push_new(&new_text)).await?;
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 = edit_end;
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<Self>) {
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<Item = Result<String>>,
) -> impl Stream<Item = Result<String>> {
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 futures::stream;
use gpui::{executor::Deterministic, TestAppContext};
use indoc::indoc;
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_transform_autoindent(
cx: &mut TestAppContext,
mut rng: StdRng,
deterministic: Arc<Deterministic>,
) {
cx.set_global(cx.read(SettingsStore::test));
cx.update(language_settings::init);
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(),
CodegenKind::Transform { range },
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);
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<Deterministic>,
) {
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<Deterministic>,
) {
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);
let (chunk, suffix) = new_text.split_at(len);
provider.send_completion(chunk);
new_text = suffix;
deterministic.run_until_parked();
}
provider.finish_completion();
deterministic.run_until_parked();
assert_eq!(
buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
indoc! {"
fn main() {
let mut x = 0;
while x < 10 {
x += 1;
}
}
"}
);
}
#[gpui::test]
async fn test_strip_markdown_codeblock() {
assert_eq!(
strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"Lorem ipsum dolor"
);
assert_eq!(
strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"```js\nLorem ipsum dolor\n```"
);
assert_eq!(
strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2))
.map(|chunk| chunk.unwrap())
.collect::<String>()
.await,
"``\nLorem ipsum dolor\n```"
);
fn chunks(text: &str, size: usize) -> impl Stream<Item = Result<String>> {
stream::iter(
text.chars()
.collect::<Vec<_>>()
.chunks(size)
.map(|chunk| Ok(chunk.iter().collect::<String>()))
.collect::<Vec<_>>(),
)
}
}
struct TestCompletionProvider {
last_completion_tx: Mutex<Option<mpsc::Sender<String>>>,
}
impl TestCompletionProvider {
fn new() -> Self {
Self {
last_completion_tx: Mutex::new(None),
}
}
fn send_completion(&self, completion: impl Into<String>) {
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<BoxStream<'static, Result<String>>>> {
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()
}
}

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
version = "0.20.0"
version = "0.21.0"
publish = false
[[bin]]

View file

@ -0,0 +1,4 @@
db-uri = "postgres://postgres@localhost/zed"
server-port = 8081
jwt-secret = "the-postgrest-jwt-secret-for-authorization"
log-level = "info"

View file

@ -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:
@ -21,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: postgrest
ports:
- name: web
protocol: TCP
port: 443
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
@ -117,3 +138,40 @@ 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: postgrest
spec:
replicas: 1
selector:
matchLabels:
app: postgrest
template:
metadata:
labels:
app: postgrest
spec:
containers:
- name: postgrest
image: "postgrest/postgrest"
ports:
- containerPort: 8080
protocol: TCP
env:
- name: PGRST_SERVER_PORT
value: "8080"
- name: PGRST_DB_URI
valueFrom:
secretKeyRef:
name: database
key: url
- name: PGRST_JWT_SECRET
valueFrom:
secretKeyRef:
name: postgrest
key: jwt_secret

View file

@ -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<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
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<String>,
page: Option<u32>,
limit: Option<u32>,
}
async fn get_users(
Query(params): Query<GetUsersQueryParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> {
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<CreateUserParams>,
Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> Result<Json<Option<CreateUserResponse>>> {
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(&params.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<bool>,
invite_count: Option<i32>,
}
async fn update_user(
Path(user_id): Path<i32>,
Json(params): Json<UpdateUserParams>,
Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> 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<i32>,
Extension(app): Extension<Arc<AppState>>,
) -> 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<GetUsersWithNoInvites>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> {
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<String>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<User>> {
Ok(Json(app.db.get_user_for_invite_code(&code).await?))
}
async fn create_signup(
Json(params): Json<NewSignup>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.create_signup(&params).await?;
Ok(())
}
async fn get_waitlist_summary(
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<WaitlistSummary>> {
Ok(Json(app.db.get_waitlist_summary().await?))
}
#[derive(Deserialize)]
pub struct CreateInviteFromCodeParams {
invite_code: String,
email_address: String,
device_id: Option<String>,
#[serde(default)]
added_to_mailing_list: bool,
}
async fn create_invite_from_code(
Json(params): Json<CreateInviteFromCodeParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Invite>> {
Ok(Json(
app.db
.create_invite_from_code(
&params.invite_code,
&params.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<GetUnsentInvitesParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<Invite>>> {
Ok(Json(app.db.get_unsent_invites(params.count).await?))
}
async fn record_sent_invites(
Json(params): Json<Vec<Invite>>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.record_sent_invites(&params).await?;
Ok(())
}

View file

@ -8,5 +8,4 @@ pub mod messages;
pub mod projects;
pub mod rooms;
pub mod servers;
pub mod signups;
pub mod users;

View file

@ -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<Invite> {
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<Option<NewUserResult>> {
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<Option<(String, i32)>> {
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<User> {
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<signup::Model> {
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<WaitlistSummary> {
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::<Vec<_>>();
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<Vec<Invite>> {
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)
}

View file

@ -123,27 +123,6 @@ impl Database {
.await
}
pub async fn get_users_with_no_invites(
&self,
invited_by_another_user: bool,
) -> Result<Vec<User>> {
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<String> {
#[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()

View file

@ -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<Database>) {
@ -1329,245 +1027,6 @@ async fn test_channel_renames(db: &Arc<Database>) {
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::<Vec<_>>();
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::<Vec<NewSignup>>();
// 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::<Vec<_>>();
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::<Vec<_>>();
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<Background> {
Deterministic::new(0).build_background()
}

View file

@ -564,9 +564,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?;
@ -579,13 +578,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? {

View file

@ -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

View file

@ -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();

View file

@ -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;
}
@ -2285,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;
}
}
}
@ -5121,9 +5129,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,
});
}
}
@ -8605,9 +8610,6 @@ pub enum Event {
local: bool,
autoscroll: bool,
},
TransactionUndone {
transaction_id: TransactionId,
},
Closed,
}

View file

@ -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<Self>) -> Option<TransactionId> {
let mut transaction_id = None;
if let Some(buffer) = self.as_singleton() {
return buffer.update(cx, |buffer, cx| buffer.undo(cx));
}
transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx));
} else {
while let Some(transaction) = self.history.pop_undo() {
let mut undone = false;
for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions {
if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) {
undone |= buffer.update(cx, |buffer, cx| {
let undo_to = *buffer_transaction_id;
if let Some(entry) = buffer.peek_undo_stack() {
*buffer_transaction_id = entry.transaction_id();
}
buffer.undo_to_transaction(undo_to, cx)
});
}
}
while let Some(transaction) = self.history.pop_undo() {
let mut undone = false;
for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions {
if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) {
undone |= buffer.update(cx, |buffer, cx| {
let undo_to = *buffer_transaction_id;
if let Some(entry) = buffer.peek_undo_stack() {
*buffer_transaction_id = entry.transaction_id();
}
buffer.undo_to_transaction(undo_to, cx)
});
if undone {
transaction_id = Some(transaction.id);
break;
}
}
if undone {
return Some(transaction.id);
}
}
None
if let Some(transaction_id) = transaction_id {
cx.emit(Event::TransactionUndone { transaction_id });
}
transaction_id
}
pub fn redo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {

View file

@ -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<Mutex<Vec<mpsc::UnboundedSender<(Arc<Language>, LanguageServerBinaryStatus)>>>>,
}
impl LspBinaryStatusSender {
fn subscribe(&self) -> mpsc::UnboundedReceiver<(Arc<Language>, LanguageServerBinaryStatus)> {
let (tx, rx) = mpsc::unbounded();
self.txs.lock().push(tx);
rx
}
fn send(&self, language: Arc<Language>, 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<Parser> = RefCell::new(Parser::new());
}
@ -594,14 +612,13 @@ struct AvailableLanguage {
pub struct LanguageRegistry {
state: RwLock<LanguageRegistryState>,
language_server_download_dir: Option<Arc<Path>>,
lsp_binary_statuses_tx: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
lsp_binary_statuses_rx: async_broadcast::Receiver<(Arc<Language>, LanguageServerBinaryStatus)>,
login_shell_env_loaded: Shared<Task<()>>,
#[allow(clippy::type_complexity)]
lsp_binary_paths: Mutex<
HashMap<LanguageServerName, Shared<Task<Result<LanguageServerBinary, Arc<anyhow::Error>>>>>,
>,
executor: Option<Arc<Background>>,
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<Path> = 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<Language>, LanguageServerBinaryStatus)> {
self.lsp_binary_statuses_rx.clone()
) -> mpsc::UnboundedReceiver<(Arc<Language>, LanguageServerBinaryStatus)> {
self.lsp_binary_status_tx.subscribe()
}
pub fn delete_server_container(
@ -1054,7 +1069,7 @@ async fn get_binary(
language: Arc<Language>,
delegate: Arc<dyn LspAdapterDelegate>,
container_dir: Arc<Path>,
statuses: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
statuses: LspBinaryStatusSender,
mut cx: AsyncAppContext,
) -> Result<LanguageServerBinary> {
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<Language>,
delegate: &dyn LspAdapterDelegate,
container_dir: &Path,
lsp_binary_statuses_tx: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
lsp_binary_statuses_tx: LspBinaryStatusSender,
) -> Result<LanguageServerBinary> {
let container_dir: Arc<Path> = 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)
}

View file

@ -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::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
}
@ -101,6 +102,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<Self>) {
if cx.is_self_focused() {
cx.focus(&self.query_editor);
@ -868,9 +884,25 @@ impl BufferSearchBar {
cx.propagate_action();
}
}
fn toggle_replace(&mut self, _: &ToggleReplace, _: &mut ViewContext<Self>) {
fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
if let Some(_) = &self.active_searchable_item {
self.replace_is_active = !self.replace_is_active;
cx.notify();
}
}
fn toggle_replace_on_a_pane(pane: &mut Pane, _: &ToggleReplace, cx: &mut ViewContext<Pane>) {
let mut should_propagate = true;
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
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<Self>) {
@ -918,12 +950,16 @@ impl BufferSearchBar {
fn replace_next_on_pane(pane: &mut Pane, action: &ReplaceNext, cx: &mut ViewContext<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
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<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |bar, cx| bar.replace_all(action, cx));
return;
}
cx.propagate_action();
}
}

View file

@ -701,8 +701,9 @@ impl ProjectSearchView {
}));
return;
}
} else {
semantic_state.maintain_rate_limit = None;
}
semantic_state.maintain_rate_limit = None;
}
}

View file

@ -110,7 +110,7 @@ fn toggle_replace_button<V: View>(
button_style: ToggleIconButtonStyle,
) -> AnyElement<V> {
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)

View file

@ -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;

View file

@ -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<Workspace>) {
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<Workspace>) {
let should_repeat = Vim::update(cx, |vim, cx| {
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| {
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<Deterministic>,
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<Deterministic>,
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;
}
}

View file

@ -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)
})
@ -219,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, cx| vim.take_count(cx));
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
}
@ -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, times), 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 {

View file

@ -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;
@ -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(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.pop_number_operator(cx);
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.pop_number_operator(cx);
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.pop_number_operator(cx);
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.pop_number_operator(cx).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 {
@ -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;
}

View file

@ -8,7 +8,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim};
pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
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(cx).unwrap_or(1) as u32;
vim.update_active_editor(cx, |editor, cx| {
let mut ranges = Vec::new();
let mut cursor_positions = Vec::new();

View file

@ -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;
}
}
}

View file

@ -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,40 @@ 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"})
.await;
cx.assert(indoc! {"
the lazy"},
["d", "g", "g"],
)
.await;
cx.assert_neovim_compatible(
indoc! {"
The quick
brown fox
jumps over
the lˇazy"})
.await;
cx.assert_exempted(
the lˇazy"},
["d", "g", "g"],
)
.await;
cx.assert_neovim_compatible(
indoc! {"
The qˇuick
brown fox
jumps over
the lazy"},
ExemptionFeatures::OperatorAbortsOnFailedMotion,
["d", "g", "g"],
)
.await;
cx.assert_exempted(
cx.assert_neovim_compatible(
indoc! {"
ˇ
brown fox
jumps over
the lazy"},
ExemptionFeatures::OperatorAbortsOnFailedMotion,
["d", "g", "g"],
)
.await;
}
@ -387,4 +397,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;
}
}

View file

@ -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,138 +18,187 @@ fn should_replay(action: &Box<dyn Action>) -> bool {
true
}
fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
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| {
vim.workspace_state.replaying = false;
vim.update_active_editor(cx, |editor, _| {
editor.show_local_selections = true;
});
vim.switch_mode(Mode::Normal, false, cx)
});
});
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.pop_number_operator(cx);
cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false));
}
vim.workspace_state.replaying = true;
let selection = vim.workspace_state.recorded_selection.clone();
match selection {
RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::Visual, false, cx)
}
RecordedSelection::VisualLine { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::VisualLine, false, cx)
}
RecordedSelection::VisualBlock { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::VisualBlock, false, cx)
}
RecordedSelection::None => {
if let Some(count) = count {
vim.workspace_state.recorded_count = Some(count);
}
}
}
if let Some(editor) = editor.upgrade(cx) {
editor.update(cx, |editor, _| {
editor.show_local_selections = false;
})
} else {
return None;
}
Some((actions, editor, selection))
}) else {
return;
};
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 => {}
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 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)
}),
}?
let Some(editor) = vim.active_editor.clone() else {
return None;
};
let count = vim.take_count(cx);
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);
}
window
.dispatch_action(editor.id(), &EndRepeat, &mut cx)
.ok_or_else(|| anyhow::anyhow!("window was closed"))
})
.detach_and_log_err(cx);
});
}
}
Some((actions, editor, selection))
}) else {
return;
};
match selection {
RecordedSelection::SingleLine { cols } => {
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::Visual { rows, cols } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
visual_motion(
Motion::StartOfLine {
display_lines: false,
},
None,
cx,
);
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::VisualBlock { rows, cols } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx);
}
}
RecordedSelection::VisualLine { rows } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
}
RecordedSelection::None => {}
}
// insert internally uses repeat to handle counts
// vim doesn't treat 3a1 as though you literally repeated a1
// 3 times, instead it inserts the content thrice at the insert position.
if let Some(to_repeat) = repeatable_insert(&actions[0]) {
if let Some(ReplayableAction::Action(action)) = actions.last() {
if action.id() == NormalBefore.id() {
actions.pop();
}
}
let mut new_actions = actions.clone();
actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1);
// if we came from insert mode we're just doing repititions 2 onwards.
if from_insert_mode {
count -= 1;
new_actions[0] = actions[0].clone();
}
for _ in 1..count {
new_actions.append(actions.clone().as_mut());
}
new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
actions = new_actions;
}
Vim::update(cx, |vim, _| vim.workspace_state.replaying = true);
let window = cx.window();
cx.app_context()
.spawn(move |mut cx| async move {
editor.update(&mut cx, |editor, _| {
editor.show_local_selections = false;
})?;
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)
}),
}?
}
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"))
})
.detach_and_log_err(cx);
}
#[cfg(test)]
@ -203,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]
@ -424,4 +474,55 @@ mod test {
})
.await;
}
#[gpui::test]
async fn test_repeat_motion_counts(
deterministic: Arc<Deterministic>,
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;
}
#[gpui::test]
async fn test_record_interrupted(
deterministic: Arc<Deterministic>,
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);
}
}

View file

@ -48,7 +48,7 @@ pub fn init(cx: &mut AppContext) {
fn scroll(cx: &mut ViewContext<Workspace>, by: fn(c: Option<f32>) -> 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(cx).map(|c| c as f32));
vim.update_active_editor(cx, |editor, cx| scroll_editor(editor, &amount, cx));
})
}

View file

@ -52,7 +52,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
Direction::Next
};
Vim::update(cx, |vim, cx| {
let count = vim.pop_number_operator(cx).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::<BufferSearchBar>() {
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(cx).unwrap_or(1);
pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
let search = search_bar.update(cx, |search_bar, cx| {

View file

@ -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(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.pop_number_operator(cx);
let count = vim.take_count(cx);
substitute(vim, count, true, cx)
})
});

View file

@ -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<usize>,
/// post_count is the number after an operator is specified (2 in 3d2d)
pub post_count: Option<usize>,
pub operator_stack: Vec<Operator>,
}
@ -158,6 +163,10 @@ impl EditorState {
}
}
pub fn active_operator(&self) -> Option<Operator> {
self.operator_stack.last().copied()
}
pub fn keymap_context_layer(&self) -> KeymapContext {
let mut context = KeymapContext::default();
context.add_identifier("VimEnabled");
@ -174,7 +183,14 @@ 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()
{
dbg!("VimCount");
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 +210,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",

View file

@ -574,3 +574,47 @@ 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.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;
}

View file

@ -13,20 +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,
ExemptionFeatures::OperatorAbortsOnFailedMotion,
];
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 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
@ -68,6 +61,8 @@ pub struct NeovimBackedTestContext<'a> {
last_set_state: Option<String>,
recent_keystrokes: Vec<String>,
is_dirty: bool,
}
impl<'a> NeovimBackedTestContext<'a> {
@ -81,6 +76,7 @@ impl<'a> NeovimBackedTestContext<'a> {
last_set_state: None,
recent_keystrokes: Default::default(),
is_dirty: false,
}
}
@ -128,6 +124,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 +150,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 +256,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 +382,17 @@ 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 {
panic!("Test context was dropped after set_shared_state before assert_shared_state")
}
}
}
#[cfg(test)]
mod test {
use gpui::TestAppContext;

View file

@ -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;
@ -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, cx| vim.push_count_digit(n.0, cx));
});
cx.add_action(|_: &mut Workspace, _: &Tab, cx| {
@ -225,23 +228,12 @@ 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;
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
@ -286,6 +278,16 @@ impl Vim {
}
}
pub fn stop_recording_immediately(&mut self, action: Box<dyn Action>) {
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();
@ -300,6 +302,9 @@ impl Vim {
state.mode = mode;
state.operator_stack.clear();
});
if mode != Mode::Insert {
self.take_count(cx);
}
cx.emit_global(VimEvent::ModeChanged { mode });
@ -352,6 +357,39 @@ impl Vim {
});
}
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)
})
} else {
self.update_state(|state| {
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, cx: &mut WindowContext) -> Option<usize> {
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;
}
self.sync_vim_settings(cx);
count
}
fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) {
if matches!(
operator,
@ -363,15 +401,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<Operator> {
self.update_state(|state| state.operator_stack.pop())
}
@ -382,22 +411,8 @@ impl Vim {
self.sync_vim_settings(cx);
popped_operator
}
fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option<usize> {
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.take_count(cx);
self.update_state(|state| state.operator_stack.clear());
self.sync_vim_settings(cx);
}

View file

@ -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"}
{"Get":{"state":"The quick brown\nfox juˇ over\nthe lazy dog","mode":"Normal"}}

View file

@ -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"}}

View file

@ -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"}}

View file

@ -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"}}

View file

@ -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"}}

View file

@ -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"}}

View file

@ -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"}}

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
version = "0.104.0"
version = "0.105.0"
publish = false
[lib]

View file

@ -13,10 +13,11 @@ 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}
target_zed_kube_cluster
envsubst < crates/collab/k8s/manifest.template.yml | kubectl apply -f -
echo "deployed collab v${version} to ${environment}"
echo "deployed collab v${version} to ${environment}"

View file

@ -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"),