From 20f98e4d17032eac76a69e939eecf86dd06172b1 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 21 Aug 2023 16:10:13 -0600 Subject: [PATCH 01/15] vim . to replay Co-Authored-By: maxbrunsfeld@gmail.com --- Cargo.lock | 2 + assets/keymaps/vim.json | 6 +- crates/editor/src/editor.rs | 143 +++++++++++++--- crates/editor/src/editor_tests.rs | 2 +- crates/gpui/src/app/window.rs | 2 +- crates/terminal_view/README.md | 8 +- crates/vim/Cargo.toml | 2 + crates/vim/src/editor_events.rs | 1 + crates/vim/src/insert.rs | 7 +- crates/vim/src/normal.rs | 31 +++- crates/vim/src/normal/case.rs | 1 + crates/vim/src/normal/delete.rs | 2 + crates/vim/src/normal/paste.rs | 1 + crates/vim/src/normal/repeat.rs | 200 ++++++++++++++++++++++ crates/vim/src/state.rs | 34 +++- crates/vim/src/test/vim_test_context.rs | 15 ++ crates/vim/src/vim.rs | 85 ++++++++- crates/vim/src/visual.rs | 2 + crates/vim/test_data/test_dot_repeat.json | 38 ++++ 19 files changed, 543 insertions(+), 39 deletions(-) create mode 100644 crates/vim/src/normal/repeat.rs create mode 100644 crates/vim/test_data/test_dot_repeat.json diff --git a/Cargo.lock b/Cargo.lock index a185542c63d0226108eca3c22e769bfa10f37770..1353fd224008e350350d15461fbc8f90d2b6ae7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8767,12 +8767,14 @@ dependencies = [ "collections", "command_palette", "editor", + "futures 0.3.28", "gpui", "indoc", "itertools", "language", "language_selector", "log", + "lsp", "nvim-rs", "parking_lot 0.11.2", "project", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index da094ea7e46ca4c8abc291a9381ac1318614bbd1..582171d8a23a6532be654a387f4a45b33f2530db 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -316,6 +316,7 @@ { "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting", "bindings": { + ".": "vim::Repeat", "c": [ "vim::PushOperator", "Change" @@ -331,10 +332,7 @@ "vim::PushOperator", "Yank" ], - "i": [ - "vim::SwitchMode", - "Insert" - ], + "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", "a": "vim::InsertAfter", "shift-a": "vim::InsertEndOfLine", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bdd29b04fa20e2ecd2492d6554f5541b2bc99540..1e6e5685b93165bbfad166ac0c86bd54b4656f68 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2269,10 +2269,6 @@ impl Editor { if self.read_only { return; } - if !self.input_enabled { - cx.emit(Event::InputIgnored { text }); - return; - } let selections = self.selections.all_adjusted(cx); let mut brace_inserted = false; @@ -3207,17 +3203,30 @@ impl Editor { .count(); let snapshot = self.buffer.read(cx).snapshot(cx); + let mut range_to_replace: Option> = None; let mut ranges = Vec::new(); for selection in &selections { if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) { let start = selection.start.saturating_sub(lookbehind); let end = selection.end + lookahead; + if selection.id == newest_selection.id { + range_to_replace = Some( + ((start + common_prefix_len) as isize - selection.start as isize) + ..(end as isize - selection.start as isize), + ); + } ranges.push(start + common_prefix_len..end); } else { common_prefix_len = 0; ranges.clear(); ranges.extend(selections.iter().map(|s| { if s.id == newest_selection.id { + range_to_replace = Some( + old_range.start.to_offset_utf16(&snapshot).0 as isize + - selection.start as isize + ..old_range.end.to_offset_utf16(&snapshot).0 as isize + - selection.start as isize, + ); old_range.clone() } else { s.start..s.end @@ -3228,6 +3237,11 @@ impl Editor { } let text = &text[common_prefix_len..]; + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + self.transact(cx, |this, cx| { if let Some(mut snippet) = snippet { snippet.text = text.to_string(); @@ -3685,6 +3699,10 @@ impl Editor { self.report_copilot_event(Some(completion.uuid.clone()), true, cx) } + cx.emit(Event::InputHandled { + utf16_range_to_replace: None, + text: suggestion.text.to_string().into(), + }); self.insert_with_autoindent_mode(&suggestion.text.to_string(), None, cx); cx.notify(); true @@ -8436,6 +8454,41 @@ impl Editor { pub fn inlay_hint_cache(&self) -> &InlayHintCache { &self.inlay_hint_cache } + + pub fn replay_insert_event( + &mut self, + text: &str, + relative_utf16_range: Option>, + cx: &mut ViewContext, + ) { + if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); + return; + } + if let Some(relative_utf16_range) = relative_utf16_range { + let selections = self.selections.all::(cx); + self.change_selections(None, cx, |s| { + let new_ranges = selections.into_iter().map(|range| { + let start = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.start), + ); + let end = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.end), + ); + start..end + }); + s.select_ranges(new_ranges); + }); + } + + self.handle_input(text, cx); + } } fn document_to_inlay_range( @@ -8524,6 +8577,10 @@ pub enum Event { InputIgnored { text: Arc, }, + InputHandled { + utf16_range_to_replace: Option>, + text: Arc, + }, ExcerptsAdded { buffer: ModelHandle, predecessor: ExcerptId, @@ -8744,29 +8801,51 @@ impl View for Editor { text: &str, cx: &mut ViewContext, ) { + if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); + return; + } + self.transact(cx, |this, cx| { - if this.input_enabled { - let new_selected_ranges = if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } else { - this.marked_text_ranges(cx) - }; + let new_selected_ranges = if let Some(range_utf16) = range_utf16 { + let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } else { + this.marked_text_ranges(cx) + }; - if let Some(new_selected_ranges) = new_selected_ranges { - this.change_selections(None, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - } + let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(new_selected_ranges) = new_selected_ranges { + this.change_selections(None, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); } this.handle_input(text, cx); }); - if !self.input_enabled { - return; - } - if let Some(transaction) = self.ime_transaction { self.buffer.update(cx, |buffer, cx| { buffer.group_until_transaction(transaction, cx); @@ -8784,6 +8863,7 @@ impl View for Editor { cx: &mut ViewContext, ) { if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); return; } @@ -8808,6 +8888,29 @@ impl View for Editor { None }; + let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + if let Some(ranges) = ranges_to_replace { this.change_selections(None, cx, |s| s.select_ranges(ranges)); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 74bd67e03a70d20afeac7be9efdbd56eee77fc73..f11639a770193e905509d2bf142eec6564b84b46 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -7807,7 +7807,7 @@ fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewCo /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range -fn handle_completion_request<'a>( +pub fn handle_completion_request<'a>( cx: &mut EditorLspTestContext<'a>, marked_string: &str, completions: Vec<&'static str>, diff --git a/crates/gpui/src/app/window.rs b/crates/gpui/src/app/window.rs index 4b8b0534d53ae70c16093125b6afa7bbd38b5c7c..09744579a939ed0bb558ba0c040dcd51d3043c02 100644 --- a/crates/gpui/src/app/window.rs +++ b/crates/gpui/src/app/window.rs @@ -1110,7 +1110,7 @@ impl<'a> WindowContext<'a> { self.window.is_fullscreen } - pub(crate) fn dispatch_action(&mut self, view_id: Option, action: &dyn Action) -> bool { + pub fn dispatch_action(&mut self, view_id: Option, action: &dyn Action) -> bool { if let Some(view_id) = view_id { self.halt_action_dispatch = false; self.visit_dispatch_path(view_id, |view_id, capture_phase, cx| { diff --git a/crates/terminal_view/README.md b/crates/terminal_view/README.md index 019460067ebf20f991b18a9cd5ce262ae7675504..ca48f545427993caf97f6f7670a23e3e309dc214 100644 --- a/crates/terminal_view/README.md +++ b/crates/terminal_view/README.md @@ -2,13 +2,13 @@ Design notes: This crate is split into two conceptual halves: - The terminal.rs file and the src/mappings/ folder, these contain the code for interacting with Alacritty and maintaining the pty event loop. Some behavior in this file is constrained by terminal protocols and standards. The Zed init function is also placed here. -- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file. +- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file. ttys are created externally, and so can fail in unexpected ways. However, GPUI currently does not have an API for models than can fail to instantiate. `TerminalBuilder` solves this by using Rust's type system to split tty instantiation into a 2 step process: first attempt to create the file handles with `TerminalBuilder::new()`, check the result, then call `TerminalBuilder::subscribe(cx)` from within a model context. The TerminalView struct abstracts over failed and successful terminals, passing focus through to the associated view and allowing clients to build a terminal without worrying about errors. -#Input +#Input There are currently many distinct paths for getting keystrokes to the terminal: @@ -18,6 +18,6 @@ There are currently many distinct paths for getting keystrokes to the terminal: 3. IME text. When the special character mappings fail, we pass the keystroke back to GPUI to hand it to the IME system. This comes back to us in the `View::replace_text_in_range()` method, and we then send that to the terminal directly, bypassing `try_keystroke()`. -4. Pasted text has a separate pathway. +4. Pasted text has a separate pathway. -Generally, there's a distinction between 'keystrokes that need to be mapped' and 'strings which need to be written'. I've attempted to unify these under the '.try_keystroke()' API and the `.input()` API (which try_keystroke uses) so we have consistent input handling across the terminal \ No newline at end of file +Generally, there's a distinction between 'keystrokes that need to be mapped' and 'strings which need to be written'. I've attempted to unify these under the '.try_keystroke()' API and the `.input()` API (which try_keystroke uses) so we have consistent input handling across the terminal diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 2d394e3dcf8c127d48a010ce54c045d7d87a4aed..5d40032024b5f78758e25d6ba0a6e865c827cf5b 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -38,6 +38,7 @@ language_selector = { path = "../language_selector"} [dev-dependencies] indoc.workspace = true parking_lot.workspace = true +futures.workspace = true editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } @@ -47,3 +48,4 @@ util = { path = "../util", features = ["test-support"] } settings = { path = "../settings" } workspace = { path = "../workspace", features = ["test-support"] } theme = { path = "../theme", features = ["test-support"] } +lsp = { path = "../lsp", features = ["test-support"] } diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 994a09aaf9439a57ed9e8cc6bd7682894d9edf19..da5c7d46eda3813c784eeff238c965528fb3cca3 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -34,6 +34,7 @@ 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.workspace_state.recording = false; if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor == editor.clone() { vim.active_editor = None; diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 537f6a15f167718d7731d5c002916abf4a5510f7..9141a02ab3550c29262f235348e9beadfde15d9e 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -11,8 +11,9 @@ pub fn init(cx: &mut AppContext) { } fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext) { - Vim::update(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { + 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); @@ -20,7 +21,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext, cx: &mut Win fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -162,12 +173,20 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.start_recording(); + vim.switch_mode(Mode::Insert, false, cx); + }); +} + fn insert_first_non_whitespace( _: &mut Workspace, _: &InsertFirstNonWhitespace, cx: &mut ViewContext, ) { Vim::update(cx, |vim, cx| { + vim.start_recording(); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -184,6 +203,7 @@ fn insert_first_non_whitespace( fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -197,6 +217,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -229,6 +250,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -260,6 +282,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 90967949bb99215ff25612b5d7c536a59369f19a..bca7af852db4b82958166f977c1b8d8816ae0f37 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -7,6 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(); let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; vim.update_active_editor(cx, |editor, cx| { let mut ranges = Vec::new(); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 56fef78e1da3ce837fa6b5d9ae2a5e23fc4f3f26..ae85acaab55a5344281ebf8ad268f9ddc54b1f27 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -4,6 +4,7 @@ use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; use gpui::WindowContext; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -37,6 +38,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m } pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 3c437f91779ba27f2f2f36c555e6574b2158094b..db451cec12343576d8c275a13ae0238aba081dbf 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -28,6 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) { fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs new file mode 100644 index 0000000000000000000000000000000000000000..20b9966a41b9a4f22ab0d0fb71f19738abd4842c --- /dev/null +++ b/crates/vim/src/normal/repeat.rs @@ -0,0 +1,200 @@ +use crate::{ + state::{Mode, ReplayableAction}, + Vim, +}; +use gpui::{actions, AppContext}; +use workspace::Workspace; + +actions!(vim, [Repeat, EndRepeat,]); + +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.switch_mode(Mode::Normal, false, cx) + }); + }); + + cx.add_action(|_: &mut Workspace, _: &Repeat, cx| { + Vim::update(cx, |vim, cx| { + let actions = vim.workspace_state.repeat_actions.clone(); + let Some(editor) = vim.active_editor.clone() else { + return; + }; + if let Some(new_count) = vim.pop_number_operator(cx) { + vim.workspace_state.recorded_count = Some(new_count); + } + vim.workspace_state.replaying = true; + + let window = cx.window(); + cx.app_context() + .spawn(move |mut cx| async move { + for action in actions { + match action { + ReplayableAction::Action(action) => window + .dispatch_action(editor.id(), action.as_ref(), &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")), + ReplayableAction::Insertion { + text, + utf16_range_to_replace, + } => editor.update(&mut cx, |editor, cx| { + editor.replay_insert_event( + &text, + utf16_range_to_replace.clone(), + cx, + ) + }), + }? + } + window + .dispatch_action(editor.id(), &EndRepeat, &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + }) + .detach_and_log_err(cx); + }); + }); +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use editor::test::editor_lsp_test_context::EditorLspTestContext; + use futures::StreamExt; + use indoc::indoc; + + use gpui::{executor::Deterministic, View}; + + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; + + #[gpui::test] + async fn test_dot_repeat(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // "o" + cx.set_shared_state("ˇhello").await; + cx.simulate_shared_keystrokes(["o", "w", "o", "r", "l", "d", "escape"]) + .await; + cx.assert_shared_state("hello\nworlˇd").await; + cx.simulate_shared_keystrokes(["."]).await; + cx.assert_shared_state("hello\nworld\nworlˇd").await; + + // "d" + cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await; + cx.simulate_shared_keystrokes(["g", "g", "."]).await; + cx.assert_shared_state("ˇ\nworld\nrld").await; + + // "p" (note that it pastes the current clipboard) + cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await; + cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."]) + .await; + cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await; + + // "~" (note that counts apply to the action taken, not . itself) + cx.set_shared_state("ˇthe quick brown fox").await; + cx.simulate_shared_keystrokes(["2", "~", "."]).await; + cx.set_shared_state("THE ˇquick brown fox").await; + cx.simulate_shared_keystrokes(["3", "."]).await; + cx.set_shared_state("THE QUIˇck brown fox").await; + cx.simulate_shared_keystrokes(["."]).await; + cx.set_shared_state("THE QUICK ˇbrown fox").await; + } + + #[gpui::test] + async fn test_repeat_ime(deterministic: Arc, cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("hˇllo", Mode::Normal); + cx.simulate_keystrokes(["i"]); + + // simulate brazilian input for ä. + cx.update_editor(|editor, cx| { + editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), cx); + editor.replace_text_in_range(None, "ä", cx); + }); + cx.simulate_keystrokes(["escape"]); + cx.assert_state("hˇällo", Mode::Normal); + cx.simulate_keystrokes(["."]); + deterministic.run_until_parked(); + cx.assert_state("hˇäällo", Mode::Normal); + } + + #[gpui::test] + async fn test_repeat_completion( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + let mut cx = VimTestContext::new_with_lsp(cx, true); + + cx.set_state( + indoc! {" + onˇe + two + three + "}, + Mode::Normal, + ); + + let mut request = + cx.handle_request::(move |_, params, _| async move { + let position = params.text_document_position.position; + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(position.clone(), position.clone()), + new_text: "first".to_string(), + })), + ..Default::default() + }, + lsp::CompletionItem { + label: "second".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(position.clone(), position.clone()), + new_text: "second".to_string(), + })), + ..Default::default() + }, + ]))) + }); + cx.simulate_keystrokes(["a", "."]); + request.next().await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.simulate_keystrokes(["down", "enter", "!", "escape"]); + + cx.assert_state( + indoc! {" + one.secondˇ! + two + three + "}, + Mode::Normal, + ); + cx.simulate_keystrokes(["j", "."]); + deterministic.run_until_parked(); + cx.assert_state( + indoc! {" + one.second! + two.secondˇ! + three + "}, + Mode::Normal, + ); + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index aacd3d26e079515e19a48d870b76c3c4d69319af..0b4f19fe134e938d487663d9ff711778ebb925d7 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,4 +1,6 @@ -use gpui::keymap_matcher::KeymapContext; +use std::{ops::Range, sync::Arc}; + +use gpui::{keymap_matcher::KeymapContext, Action}; use language::CursorShape; use serde::{Deserialize, Serialize}; use workspace::searchable::Direction; @@ -52,6 +54,36 @@ pub struct EditorState { pub struct WorkspaceState { pub search: SearchState, pub last_find: Option, + + pub recording: bool, + pub stop_recording_after_next_action: bool, + pub replaying: bool, + pub recorded_count: Option, + pub repeat_actions: Vec, +} + +#[derive(Debug)] +pub enum ReplayableAction { + Action(Box), + Insertion { + text: Arc, + utf16_range_to_replace: Option>, + }, +} + +impl Clone for ReplayableAction { + fn clone(&self) -> Self { + match self { + Self::Action(action) => Self::Action(action.boxed_clone()), + Self::Insertion { + text, + utf16_range_to_replace, + } => Self::Insertion { + text: text.clone(), + utf16_range_to_replace: utf16_range_to_replace.clone(), + }, + } + } } #[derive(Clone)] diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 9b0373957035213b6de019425f4484aaa647920a..7cee32037393e05e4b097826f71eaeb25f27bc65 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -3,7 +3,9 @@ use std::ops::{Deref, DerefMut}; use editor::test::{ editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, }; +use futures::Future; use gpui::ContextHandle; +use lsp::request; use search::{BufferSearchBar, ProjectSearchBar}; use crate::{state::Operator, *}; @@ -124,6 +126,19 @@ impl<'a> VimTestContext<'a> { assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); } + + pub fn handle_request( + &self, + handler: F, + ) -> futures::channel::mpsc::UnboundedReceiver<()> + where + T: 'static + request::Request, + T::Params: 'static + Send, + F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, + Fut: 'static + Send + Future>, + { + self.cx.handle_request::(handler) + } } impl<'a> Deref for VimTestContext<'a> { diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index da1c634682f1511c197cb32687502422606cf387..48d34d70940de76d208e644802bc9e6ca2b69f3d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -25,10 +25,12 @@ use normal::normal_replace; use serde::Deserialize; use settings::{Setting, SettingsStore}; use state::{EditorState, Mode, Operator, WorkspaceState}; -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; use visual::{visual_block_motion, visual_replace}; use workspace::{self, Workspace}; +use crate::state::ReplayableAction; + struct VimModeSetting(bool); #[derive(Clone, Deserialize, PartialEq)] @@ -102,6 +104,19 @@ pub fn observe_keystrokes(cx: &mut WindowContext) { return true; } if let Some(handled_by) = handled_by { + Vim::update(cx, |vim, _| { + if vim.workspace_state.recording { + vim.workspace_state + .repeat_actions + .push(ReplayableAction::Action(handled_by.boxed_clone())); + + if vim.workspace_state.stop_recording_after_next_action { + vim.workspace_state.recording = false; + vim.workspace_state.stop_recording_after_next_action = false; + } + } + }); + // Keystroke is handled by the vim system, so continue forward if handled_by.namespace() == "vim" { return true; @@ -156,7 +171,12 @@ impl Vim { } Event::InputIgnored { text } => { Vim::active_editor_input_ignored(text.clone(), cx); + Vim::record_insertion(text, None, cx) } + Event::InputHandled { + text, + utf16_range_to_replace: range_to_replace, + } => Vim::record_insertion(text, range_to_replace.clone(), cx), _ => {} })); @@ -176,6 +196,27 @@ impl Vim { self.sync_vim_settings(cx); } + fn record_insertion( + text: &Arc, + range_to_replace: Option>, + cx: &mut WindowContext, + ) { + Vim::update(cx, |vim, _| { + if vim.workspace_state.recording { + vim.workspace_state + .repeat_actions + .push(ReplayableAction::Insertion { + text: text.clone(), + utf16_range_to_replace: range_to_replace, + }); + if vim.workspace_state.stop_recording_after_next_action { + vim.workspace_state.recording = false; + vim.workspace_state.stop_recording_after_next_action = false; + } + } + }); + } + fn update_active_editor( &self, cx: &mut WindowContext, @@ -184,6 +225,36 @@ 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) { + if !self.workspace_state.replaying { + self.workspace_state.recording = true; + self.workspace_state.repeat_actions = Default::default(); + self.workspace_state.recorded_count = + if let Some(Operator::Number(number)) = self.active_operator() { + Some(number) + } else { + None + } + } + } + + pub fn stop_recording(&mut self) { + if self.workspace_state.recording { + self.workspace_state.stop_recording_after_next_action = true; + } + } + + pub fn record_current_action(&mut self) { + self.start_recording(); + self.stop_recording(); + } fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) { let state = self.state(); @@ -247,6 +318,12 @@ impl Vim { } fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { + if matches!( + operator, + Operator::Change | Operator::Delete | Operator::Replace + ) { + self.start_recording() + }; self.update_state(|state| state.operator_stack.push(operator)); self.sync_vim_settings(cx); } @@ -272,6 +349,12 @@ impl Vim { } fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option { + if self.workspace_state.replaying { + if let Some(number) = self.workspace_state.recorded_count { + return Some(number); + } + } + if let Some(Operator::Number(number)) = self.active_operator() { self.pop_operator(cx); return Some(number); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index ee46a0d209f348f3fc25c2252ca9a8a06921fd68..b7ea0811f0a93ebec413e72bb5d088ab7ea8366f 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -277,6 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(); vim.update_active_editor(cx, |editor, cx| { let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = editor.selections.line_mode; @@ -339,6 +340,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) pub(crate) fn visual_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { let (display_map, selections) = editor.selections.all_adjusted_display(cx); diff --git a/crates/vim/test_data/test_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json new file mode 100644 index 0000000000000000000000000000000000000000..f1a1a3c138509420d6e0e92daf679fb347a6e673 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat.json @@ -0,0 +1,38 @@ +{"Put":{"state":"ˇhello"}} +{"Key":"o"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"l"} +{"Key":"d"} +{"Key":"escape"} +{"Get":{"state":"hello\nworlˇd","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nworld\nworlˇd","mode":"Normal"}} +{"Key":"^"} +{"Key":"d"} +{"Key":"f"} +{"Key":"o"} +{"Key":"g"} +{"Key":"g"} +{"Key":"."} +{"Get":{"state":"ˇ\nworld\nrld","mode":"Normal"}} +{"Key":"j"} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Key":"shift-g"} +{"Key":"y"} +{"Key":"y"} +{"Key":"."} +{"Get":{"state":"\nworld\nworld\nrld\nˇrld","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown fox"}} +{"Key":"2"} +{"Key":"~"} +{"Key":"."} +{"Put":{"state":"THE ˇquick brown fox"}} +{"Key":"3"} +{"Key":"."} +{"Put":{"state":"THE QUIˇck brown fox"}} +{"Key":"."} +{"Put":{"state":"THE QUICK ˇbrown fox"}} From f22d53eef9198e64d7e1c72d7d3875af21727e26 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 6 Sep 2023 14:14:49 -0600 Subject: [PATCH 02/15] Make test more deterministic Otherwise these pass only when --features=neovim is set --- crates/vim/src/normal/repeat.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 20b9966a41b9a4f22ab0d0fb71f19738abd4842c..7f2b8c4434c7d2546eb9512b35154e0d1a89ee6a 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -71,7 +71,7 @@ mod test { }; #[gpui::test] - async fn test_dot_repeat(cx: &mut gpui::TestAppContext) { + async fn test_dot_repeat(deterministic: Arc, cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; // "o" @@ -80,26 +80,33 @@ mod test { .await; cx.assert_shared_state("hello\nworlˇd").await; cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); cx.assert_shared_state("hello\nworld\nworlˇd").await; // "d" cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await; cx.simulate_shared_keystrokes(["g", "g", "."]).await; + deterministic.run_until_parked(); cx.assert_shared_state("ˇ\nworld\nrld").await; // "p" (note that it pastes the current clipboard) cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await; cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."]) .await; + deterministic.run_until_parked(); cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await; // "~" (note that counts apply to the action taken, not . itself) cx.set_shared_state("ˇthe quick brown fox").await; cx.simulate_shared_keystrokes(["2", "~", "."]).await; + deterministic.run_until_parked(); cx.set_shared_state("THE ˇquick brown fox").await; cx.simulate_shared_keystrokes(["3", "."]).await; + deterministic.run_until_parked(); cx.set_shared_state("THE QUIˇck brown fox").await; + deterministic.run_until_parked(); cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); cx.set_shared_state("THE QUICK ˇbrown fox").await; } From 1b1d7f22cce914c0dc1c17631680c41333101597 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 6 Sep 2023 16:31:52 -0600 Subject: [PATCH 03/15] Add visual area repeating --- assets/keymaps/vim.json | 6 +- crates/editor/src/editor.rs | 2 +- crates/vim/src/motion.rs | 8 +- crates/vim/src/normal.rs | 20 +- crates/vim/src/normal/case.rs | 15 +- crates/vim/src/normal/paste.rs | 2 +- crates/vim/src/normal/repeat.rs | 268 ++++++++++++++++--- crates/vim/src/normal/substitute.rs | 2 + crates/vim/src/state.rs | 23 +- crates/vim/src/vim.rs | 53 +++- crates/vim/src/visual.rs | 2 +- crates/vim/test_data/test_change_case.json | 5 + crates/vim/test_data/test_repeat_visual.json | 51 ++++ 13 files changed, 393 insertions(+), 64 deletions(-) create mode 100644 crates/vim/test_data/test_repeat_visual.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 582171d8a23a6532be654a387f4a45b33f2530db..2027943a0f3c09a7549f5c99ca5799d51f02f927 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -446,12 +446,10 @@ ], "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", + "shift-r": "vim::SubstituteLine", "c": "vim::Substitute", "~": "vim::ChangeCase", - "shift-i": [ - "vim::SwitchMode", - "Insert" - ], + "shift-i": "vim::InsertBefore", "shift-a": "vim::InsertAfter", "r": [ "vim::PushOperator", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1e6e5685b93165bbfad166ac0c86bd54b4656f68..50a382439a1d2fa824271d41476dad9f2944912d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -572,7 +572,7 @@ pub struct Editor { project: Option>, focused: bool, blink_manager: ModelHandle, - show_local_selections: bool, + pub show_local_selections: bool, mode: EditorMode, replica_id_mapping: Option>, show_gutter: bool, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 16bccb696338e229e01cf39ec8ecc496bda62217..48f502639cb74841e6eed14121c51dfb1d030ab9 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -65,9 +65,9 @@ struct PreviousWordStart { #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -struct Up { +pub(crate) struct Up { #[serde(default)] - display_lines: bool, + pub(crate) display_lines: bool, } #[derive(Clone, Deserialize, PartialEq)] @@ -93,9 +93,9 @@ struct EndOfLine { #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -struct StartOfLine { +pub struct StartOfLine { #[serde(default)] - display_lines: bool, + pub(crate) display_lines: bool, } #[derive(Clone, Deserialize, PartialEq)] diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 310883d1d69a8dac7f85eaa9908f89a6cb6379ac..25a5ba5131e39bb94966e9d87bb5b8b21f884cbb 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -66,21 +66,21 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); let times = vim.pop_number_operator(cx); delete_motion(vim, Motion::Left, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); let times = vim.pop_number_operator(cx); delete_motion(vim, Motion::Right, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); let times = vim.pop_number_operator(cx); change_motion( vim, @@ -94,7 +94,7 @@ pub fn init(cx: &mut AppContext) { }); cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); let times = vim.pop_number_operator(cx); delete_motion( vim, @@ -161,7 +161,7 @@ fn move_cursor(vim: &mut Vim, motion: Motion, times: Option, cx: &mut Win fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -175,7 +175,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); }); } @@ -186,7 +186,7 @@ fn insert_first_non_whitespace( cx: &mut ViewContext, ) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -203,7 +203,7 @@ fn insert_first_non_whitespace( fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -217,7 +217,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -250,7 +250,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.start_recording(); + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index bca7af852db4b82958166f977c1b8d8816ae0f37..12fd8dbd2b66df8ef94ea61e9f80718769c6a28c 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -7,7 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; vim.update_active_editor(cx, |editor, cx| { let mut ranges = Vec::new(); @@ -22,10 +22,16 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext { + Mode::Visual => { ranges.push(selection.start..selection.end); cursor_positions.push(selection.start..selection.start); } + Mode::VisualBlock => { + ranges.push(selection.start..selection.end); + if cursor_positions.len() == 0 { + cursor_positions.push(selection.start..selection.start); + } + } Mode::Insert | Mode::Normal => { let start = selection.start; let mut end = start; @@ -97,6 +103,11 @@ mod test { cx.simulate_shared_keystrokes(["shift-v", "~"]).await; cx.assert_shared_state("ˇABc\n").await; + // works in visual block mode + cx.set_shared_state("ˇaa\nbb\ncc").await; + cx.simulate_shared_keystrokes(["ctrl-v", "j", "~"]).await; + cx.assert_shared_state("ˇAa\nBb\ncc").await; + // works with multiple cursors (zed only) cx.set_state("aˇßcdˇe\n", Mode::Normal); cx.simulate_keystroke("~"); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index db451cec12343576d8c275a13ae0238aba081dbf..dda8dea1e480fcbf07a7df7f66b7846b27ee3d32 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -28,7 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) { fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 7f2b8c4434c7d2546eb9512b35154e0d1a89ee6a..a29141941358ae925bd32902721c2249c4f8f404 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -1,5 +1,7 @@ use crate::{ - state::{Mode, ReplayableAction}, + motion::Motion, + state::{Mode, RecordedSelection, ReplayableAction}, + visual::visual_motion, Vim, }; use gpui::{actions, AppContext}; @@ -11,47 +13,127 @@ 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| { - Vim::update(cx, |vim, cx| { - let actions = vim.workspace_state.repeat_actions.clone(); + 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; + return None; }; - if let Some(new_count) = vim.pop_number_operator(cx) { - vim.workspace_state.recorded_count = Some(new_count); - } + let count = vim.pop_number_operator(cx); + vim.workspace_state.replaying = true; - let window = cx.window(); - cx.app_context() - .spawn(move |mut cx| async move { - for action in actions { - match action { - ReplayableAction::Action(action) => window - .dispatch_action(editor.id(), action.as_ref(), &mut cx) - .ok_or_else(|| anyhow::anyhow!("window was closed")), - 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 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")) + } + } + + if let Some(editor) = editor.upgrade(cx) { + editor.update(cx, |editor, _| { + editor.show_local_selections = false; }) - .detach_and_log_err(cx); - }); + } else { + return None; + } + + Some((actions, editor, selection)) + }) else { + return; + }; + + match selection { + RecordedSelection::SingleLine { cols } => { + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::Visual { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + visual_motion( + Motion::StartOfLine { + display_lines: false, + }, + None, + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::VisualBlock { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx); + } + } + RecordedSelection::VisualLine { rows } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + } + RecordedSelection::None => {} + } + + let window = cx.window(); + cx.app_context() + .spawn(move |mut cx| async move { + for action in actions { + match action { + ReplayableAction::Action(action) => window + .dispatch_action(editor.id(), action.as_ref(), &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")), + ReplayableAction::Insertion { + text, + utf16_range_to_replace, + } => editor.update(&mut cx, |editor, cx| { + editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) + }), + }? + } + window + .dispatch_action(editor.id(), &EndRepeat, &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + }) + .detach_and_log_err(cx); }); } @@ -204,4 +286,128 @@ mod test { Mode::Normal, ); } + + #[gpui::test] + async fn test_repeat_visual(deterministic: Arc, cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // single-line (3 columns) + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇo quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "w", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o quick brown + fox ˇops over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["f", "r", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o quick brown + fox ops oveˇothe lazy dog" + }) + .await; + + // visual + cx.set_shared_state(indoc! { + "the ˇquick brown + fox jumps over + fox jumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["v", "j", "x"]).await; + cx.assert_shared_state(indoc! { + "the ˇumps over + fox jumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the ˇumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["w", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the umps ˇumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the umps umps over + the ˇog" + }) + .await; + + // block mode (3 rows) + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇothe quick brown + ofox jumps over + othe lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "othe quick brown + ofoxˇo jumps over + otheo lazy dog" + }) + .await; + + // line mode + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇo + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o + ˇo + the lazy dog" + }) + .await; + } } diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 23b545abd84f4435a92d10b107fbb8b4e09f20ed..d0dbb9e3068c9de28b2fe90258ef22112f9c8bb3 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -10,6 +10,7 @@ actions!(vim, [Substitute, SubstituteLine]); 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); substitute(vim, count, vim.state().mode == Mode::VisualLine, cx); }) @@ -17,6 +18,7 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0b4f19fe134e938d487663d9ff711778ebb925d7..7359178f0eba91b06780301f4ddc6b00c03b97e5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -50,6 +50,26 @@ pub struct EditorState { pub operator_stack: Vec, } +#[derive(Default, Clone, Debug)] +pub enum RecordedSelection { + #[default] + None, + Visual { + rows: u32, + cols: u32, + }, + SingleLine { + cols: u32, + }, + VisualBlock { + rows: u32, + cols: u32, + }, + VisualLine { + rows: u32, + }, +} + #[derive(Default, Clone)] pub struct WorkspaceState { pub search: SearchState, @@ -59,7 +79,8 @@ pub struct WorkspaceState { pub stop_recording_after_next_action: bool, pub replaying: bool, pub recorded_count: Option, - pub repeat_actions: Vec, + pub recorded_actions: Vec, + pub recorded_selection: RecordedSelection, } #[derive(Debug)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 48d34d70940de76d208e644802bc9e6ca2b69f3d..35abdaf834bc7af3172bbb745395cd820032fb2d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -18,13 +18,13 @@ use gpui::{ actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; -use language::{CursorShape, Selection, SelectionGoal}; +use language::{CursorShape, Point, Selection, SelectionGoal}; pub use mode_indicator::ModeIndicator; use motion::Motion; use normal::normal_replace; use serde::Deserialize; use settings::{Setting, SettingsStore}; -use state::{EditorState, Mode, Operator, WorkspaceState}; +use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState}; use std::{ops::Range, sync::Arc}; use visual::{visual_block_motion, visual_replace}; use workspace::{self, Workspace}; @@ -107,7 +107,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) { Vim::update(cx, |vim, _| { if vim.workspace_state.recording { vim.workspace_state - .repeat_actions + .recorded_actions .push(ReplayableAction::Action(handled_by.boxed_clone())); if vim.workspace_state.stop_recording_after_next_action { @@ -204,7 +204,7 @@ impl Vim { Vim::update(cx, |vim, _| { if vim.workspace_state.recording { vim.workspace_state - .repeat_actions + .recorded_actions .push(ReplayableAction::Insertion { text: text.clone(), utf16_range_to_replace: range_to_replace, @@ -232,16 +232,51 @@ impl Vim { // TODO: shift-j? // - pub fn start_recording(&mut self) { + pub fn start_recording(&mut self, cx: &mut WindowContext) { if !self.workspace_state.replaying { self.workspace_state.recording = true; - self.workspace_state.repeat_actions = Default::default(); + 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 + }; + + let selections = self + .active_editor + .and_then(|editor| editor.upgrade(cx)) + .map(|editor| { + let editor = editor.read(cx); + ( + editor.selections.oldest::(cx), + editor.selections.newest::(cx), + ) + }); + + if let Some((oldest, newest)) = selections { + self.workspace_state.recorded_selection = match self.state().mode { + Mode::Visual if newest.end.row == newest.start.row => { + RecordedSelection::SingleLine { + cols: newest.end.column - newest.start.column, + } + } + Mode::Visual => RecordedSelection::Visual { + rows: newest.end.row - newest.start.row, + cols: newest.end.column, + }, + Mode::VisualLine => RecordedSelection::VisualLine { + rows: newest.end.row - newest.start.row, + }, + Mode::VisualBlock => RecordedSelection::VisualBlock { + rows: newest.end.row.abs_diff(oldest.start.row), + cols: newest.end.column.abs_diff(oldest.start.column), + }, + _ => RecordedSelection::None, } + } else { + self.workspace_state.recorded_selection = RecordedSelection::None; + } } } @@ -251,8 +286,8 @@ impl Vim { } } - pub fn record_current_action(&mut self) { - self.start_recording(); + pub fn record_current_action(&mut self, cx: &mut WindowContext) { + self.start_recording(cx); self.stop_recording(); } @@ -322,7 +357,7 @@ impl Vim { operator, Operator::Change | Operator::Delete | Operator::Replace ) { - self.start_recording() + self.start_recording(cx) }; self.update_state(|state| state.operator_stack.push(operator)); self.sync_vim_settings(cx); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index b7ea0811f0a93ebec413e72bb5d088ab7ea8366f..acd55a0954ac9d4c9a5214237e7fd451f81be795 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -277,7 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.record_current_action(); + vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = editor.selections.line_mode; diff --git a/crates/vim/test_data/test_change_case.json b/crates/vim/test_data/test_change_case.json index 1c0cad0b935a91b31ad9e54f3548b9af117e929c..10eb93b2279339b5f032569878f479efa701b718 100644 --- a/crates/vim/test_data/test_change_case.json +++ b/crates/vim/test_data/test_change_case.json @@ -16,3 +16,8 @@ {"Key":"shift-v"} {"Key":"~"} {"Get":{"state":"ˇABc\n","mode":"Normal"}} +{"Put":{"state":"ˇaa\nbb\ncc"}} +{"Key":"ctrl-v"} +{"Key":"j"} +{"Key":"~"} +{"Get":{"state":"ˇAa\nBb\ncc","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_visual.json b/crates/vim/test_data/test_repeat_visual.json new file mode 100644 index 0000000000000000000000000000000000000000..cb83addcfb412e51c4477be189a95b7c72eee39c --- /dev/null +++ b/crates/vim/test_data/test_repeat_visual.json @@ -0,0 +1,51 @@ +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"w"} +{"Key":"s"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇo quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"w"} +{"Key":"."} +{"Get":{"state":"o quick brown\nfox ˇops over\nthe lazy dog","mode":"Normal"}} +{"Key":"f"} +{"Key":"r"} +{"Key":"."} +{"Get":{"state":"o quick brown\nfox ops oveˇothe lazy dog","mode":"Normal"}} +{"Put":{"state":"the ˇquick brown\nfox jumps over\nfox jumps over\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"j"} +{"Key":"x"} +{"Get":{"state":"the ˇumps over\nfox jumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"the ˇumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"w"} +{"Key":"."} +{"Get":{"state":"the umps ˇumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"."} +{"Get":{"state":"the umps umps over\nthe ˇog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"ctrl-v"} +{"Key":"j"} +{"Key":"j"} +{"Key":"shift-i"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇothe quick brown\nofox jumps over\nothe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"4"} +{"Key":"l"} +{"Key":"."} +{"Get":{"state":"othe quick brown\nofoxˇo jumps over\notheo lazy dog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"shift-v"} +{"Key":"shift-r"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇo\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"."} +{"Get":{"state":"o\nˇo\nthe lazy dog","mode":"Normal"}} From 48bb2a3321f5aeebc7f830760202b8ef317579f6 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 7 Sep 2023 10:51:18 -0600 Subject: [PATCH 04/15] TEMP --- assets/keymaps/vim.json | 2 +- crates/vim/src/normal.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 2027943a0f3c09a7549f5c99ca5799d51f02f927..f7fdb57d9dab3288b3dcadfbaa780250d598bd99 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -327,7 +327,7 @@ "Delete" ], "shift-d": "vim::DeleteToEndOfLine", - "shift-j": "editor::JoinLines", + "shift-j": "vim::JoinLines", "y": [ "vim::PushOperator", "Yank" diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 25a5ba5131e39bb94966e9d87bb5b8b21f884cbb..d328f663c556899ebe20717627986f8311ddef07 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -46,6 +46,7 @@ actions!( DeleteToEndOfLine, Yank, ChangeCase, + JoinLines, ] ); @@ -106,6 +107,19 @@ pub fn init(cx: &mut AppContext) { ); }) }); + cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| { + Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); + let times = vim.pop_number_operator(cx).unwrap_or(1); + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + for _ in 0..times { + editor.join_lines(editor::JoinLines, cx) + } + }) + }) + }) + }) } pub fn normal_motion( From 8e2e00e00377600cd261bd1c43c9476991bd2a5f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 7 Sep 2023 11:08:07 -0600 Subject: [PATCH 05/15] add vim-specific J (with repeatability) --- assets/keymaps/vim.json | 1 + crates/vim/src/normal.rs | 11 ++++- crates/vim/src/test.rs | 49 +++++++++++++++++++++++ crates/vim/test_data/test_join_lines.json | 13 ++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 crates/vim/test_data/test_join_lines.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f7fdb57d9dab3288b3dcadfbaa780250d598bd99..45891adee66385bc748439294bc51cd533eab2ef 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -451,6 +451,7 @@ "~": "vim::ChangeCase", "shift-i": "vim::InsertBefore", "shift-a": "vim::InsertAfter", + "shift-j": "vim::JoinLines", "r": [ "vim::PushOperator", "Replace" diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index d328f663c556899ebe20717627986f8311ddef07..63bfe9bd1ecb34e16a2ba365baccd22b1a7a23b6 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -110,11 +110,18 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); - let times = vim.pop_number_operator(cx).unwrap_or(1); + let mut times = vim.pop_number_operator(cx).unwrap_or(1); + if vim.state().mode.is_visual() { + times = 1; + } else if times > 1 { + // 2J joins two lines together (same as J or 1J) + times -= 1; + } + vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { for _ in 0..times { - editor.join_lines(editor::JoinLines, cx) + editor.join_lines(&Default::default(), cx) } }) }) diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index c6a212d77f1d91118defa0b3c8097f6c38f112ab..9d3a141a9e8abc0cf3abcb622cc4881a78fe0af5 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -286,6 +286,55 @@ async fn test_word_characters(cx: &mut gpui::TestAppContext) { ) } +#[gpui::test] +async fn test_join_lines(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["shift-j"]).await; + cx.assert_shared_state(indoc! {" + oneˇ two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["3", "shift-j"]).await; + cx.assert_shared_state(indoc! {" + one two threeˇ four + five + six + "}) + .await; + + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["j", "v", "3", "j", "shift-j"]) + .await; + cx.assert_shared_state(indoc! {" + one + two three fourˇ five + six + "}) + .await; +} + #[gpui::test] async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/test_data/test_join_lines.json b/crates/vim/test_data/test_join_lines.json new file mode 100644 index 0000000000000000000000000000000000000000..b4bc5c30e1ce7a96e2fa9ada59efa48e0aa83a0e --- /dev/null +++ b/crates/vim/test_data/test_join_lines.json @@ -0,0 +1,13 @@ +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\n"}} +{"Key":"shift-j"} +{"Get":{"state":"oneˇ two\nthree\nfour\nfive\nsix\n","mode":"Normal"}} +{"Key":"3"} +{"Key":"shift-j"} +{"Get":{"state":"one two threeˇ four\nfive\nsix\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\n"}} +{"Key":"j"} +{"Key":"v"} +{"Key":"3"} +{"Key":"j"} +{"Key":"shift-j"} +{"Get":{"state":"one\ntwo three fourˇ five\nsix\n","mode":"Normal"}} From 74ccb3df637a7ba7228e2eb30266d97e40eae0c7 Mon Sep 17 00:00:00 2001 From: Julia Date: Fri, 8 Sep 2023 12:09:31 -0400 Subject: [PATCH 06/15] Fix Python's cached binary retrieval being borked Co-Authored-By: Max Brunsfeld --- crates/zed/src/languages/python.rs | 38 +++++++++--------------------- crates/zed/src/languages/rust.rs | 1 + 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/crates/zed/src/languages/python.rs b/crates/zed/src/languages/python.rs index c1539e9590e112e6e54a61aece7a7169e6361c15..c10d605a38948710e04129c61a9e74e571dc646d 100644 --- a/crates/zed/src/languages/python.rs +++ b/crates/zed/src/languages/python.rs @@ -1,6 +1,5 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; use async_trait::async_trait; -use futures::StreamExt; use language::{LanguageServerName, LspAdapter, LspAdapterDelegate}; use lsp::LanguageServerBinary; use node_runtime::NodeRuntime; @@ -164,31 +163,16 @@ async fn get_cached_server_binary( container_dir: PathBuf, node: &dyn NodeRuntime, ) -> Option { - (|| async move { - let mut last_version_dir = None; - let mut entries = fs::read_dir(&container_dir).await?; - while let Some(entry) = entries.next().await { - let entry = entry?; - if entry.file_type().await?.is_dir() { - last_version_dir = Some(entry.path()); - } - } - let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?; - let server_path = last_version_dir.join(SERVER_PATH); - if server_path.exists() { - Ok(LanguageServerBinary { - path: node.binary_path().await?, - arguments: server_binary_arguments(&server_path), - }) - } else { - Err(anyhow!( - "missing executable in directory {:?}", - last_version_dir - )) - } - })() - .await - .log_err() + let server_path = container_dir.join(SERVER_PATH); + if server_path.exists() { + Some(LanguageServerBinary { + path: node.binary_path().await.log_err()?, + arguments: server_binary_arguments(&server_path), + }) + } else { + log::error!("missing executable in directory {:?}", server_path); + None + } } #[cfg(test)] diff --git a/crates/zed/src/languages/rust.rs b/crates/zed/src/languages/rust.rs index 854eeb7e085845b9bf212692bd9e546d067df0dc..62bdddab5bdd24b2925616b79750ac3d0c714347 100644 --- a/crates/zed/src/languages/rust.rs +++ b/crates/zed/src/languages/rust.rs @@ -262,6 +262,7 @@ impl LspAdapter for RustLspAdapter { }) } } + async fn get_cached_server_binary(container_dir: PathBuf) -> Option { (|| async move { let mut last = None; From 5f897f45a883b1fb4e0bf0840e74de56136e1505 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 7 Sep 2023 10:42:47 -0600 Subject: [PATCH 07/15] Fix f,t on soft-wrapped lines Also remove the (dangerously confusing) display_map.find_while --- crates/editor/src/display_map.rs | 89 +----------------- crates/vim/src/motion.rs | 95 +++++++++++--------- crates/vim/src/normal.rs | 1 + crates/vim/src/test.rs | 7 ++ crates/vim/src/vim.rs | 10 ++- crates/vim/test_data/test_wrapped_lines.json | 6 ++ 6 files changed, 76 insertions(+), 132 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index e8e15a927eaacabc37c88884094a97f105bf9bb7..f306692b5e420ac7101e95ec2a38cc5d8f00669c 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -555,67 +555,6 @@ impl DisplaySnapshot { }) } - /// Returns an iterator of the start positions of the occurrences of `target` in the `self` after `from` - /// Stops if `condition` returns false for any of the character position pairs observed. - pub fn find_while<'a>( - &'a self, - from: DisplayPoint, - target: &str, - condition: impl FnMut(char, DisplayPoint) -> bool + 'a, - ) -> impl Iterator + 'a { - Self::find_internal(self.chars_at(from), target.chars().collect(), condition) - } - - /// Returns an iterator of the end positions of the occurrences of `target` in the `self` before `from` - /// Stops if `condition` returns false for any of the character position pairs observed. - pub fn reverse_find_while<'a>( - &'a self, - from: DisplayPoint, - target: &str, - condition: impl FnMut(char, DisplayPoint) -> bool + 'a, - ) -> impl Iterator + 'a { - Self::find_internal( - self.reverse_chars_at(from), - target.chars().rev().collect(), - condition, - ) - } - - fn find_internal<'a>( - iterator: impl Iterator + 'a, - target: Vec, - mut condition: impl FnMut(char, DisplayPoint) -> bool + 'a, - ) -> impl Iterator + 'a { - // List of partial matches with the index of the last seen character in target and the starting point of the match - let mut partial_matches: Vec<(usize, DisplayPoint)> = Vec::new(); - iterator - .take_while(move |(ch, point)| condition(*ch, *point)) - .filter_map(move |(ch, point)| { - if Some(&ch) == target.get(0) { - partial_matches.push((0, point)); - } - - let mut found = None; - // Keep partial matches that have the correct next character - partial_matches.retain_mut(|(match_position, match_start)| { - if target.get(*match_position) == Some(&ch) { - *match_position += 1; - if *match_position == target.len() { - found = Some(match_start.clone()); - // This match is completed. No need to keep tracking it - false - } else { - true - } - } else { - false - } - }); - - found - }) - } - pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 { let mut count = 0; let mut column = 0; @@ -933,7 +872,7 @@ pub mod tests { use smol::stream::StreamExt; use std::{env, sync::Arc}; use theme::SyntaxTheme; - use util::test::{marked_text_offsets, marked_text_ranges, sample_text}; + use util::test::{marked_text_ranges, sample_text}; use Bias::*; #[gpui::test(iterations = 100)] @@ -1744,32 +1683,6 @@ pub mod tests { ) } - #[test] - fn test_find_internal() { - assert("This is a ˇtest of find internal", "test"); - assert("Some text ˇaˇaˇaa with repeated characters", "aa"); - - fn assert(marked_text: &str, target: &str) { - let (text, expected_offsets) = marked_text_offsets(marked_text); - - let chars = text - .chars() - .enumerate() - .map(|(index, ch)| (ch, DisplayPoint::new(0, index as u32))); - let target = target.chars(); - - assert_eq!( - expected_offsets - .into_iter() - .map(|offset| offset as u32) - .collect::>(), - DisplaySnapshot::find_internal(chars, target.collect(), |_, _| true) - .map(|point| point.column()) - .collect::>() - ) - } - } - fn syntax_chunks<'a>( rows: Range, map: &ModelHandle, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 16bccb696338e229e01cf39ec8ecc496bda62217..6d83642648b592f4b55035efd46c77c686568b89 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,9 +1,9 @@ -use std::{cmp, sync::Arc}; +use std::cmp; use editor::{ char_kind, display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, - movement::{self, FindRange}, + movement::{self, find_boundary, find_preceding_boundary, FindRange}, Bias, CharKind, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, AppContext, WindowContext}; @@ -37,8 +37,8 @@ pub enum Motion { StartOfDocument, EndOfDocument, Matching, - FindForward { before: bool, text: Arc }, - FindBackward { after: bool, text: Arc }, + FindForward { before: bool, char: char }, + FindBackward { after: bool, char: char }, NextLineStart, } @@ -233,25 +233,25 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { fn repeat_motion(backwards: bool, cx: &mut WindowContext) { let find = match Vim::read(cx).workspace_state.last_find.clone() { - Some(Motion::FindForward { before, text }) => { + Some(Motion::FindForward { before, char }) => { if backwards { Motion::FindBackward { after: before, - text, + char, } } else { - Motion::FindForward { before, text } + Motion::FindForward { before, char } } } - Some(Motion::FindBackward { after, text }) => { + Some(Motion::FindBackward { after, char }) => { if backwards { Motion::FindForward { before: after, - text, + char, } } else { - Motion::FindBackward { after, text } + Motion::FindBackward { after, char } } } _ => return, @@ -403,12 +403,12 @@ impl Motion { SelectionGoal::None, ), Matching => (matching(map, point), SelectionGoal::None), - FindForward { before, text } => ( - find_forward(map, point, *before, text.clone(), times), + FindForward { before, char } => ( + find_forward(map, point, *before, *char, times), SelectionGoal::None, ), - FindBackward { after, text } => ( - find_backward(map, point, *after, text.clone(), times), + FindBackward { after, char } => ( + find_backward(map, point, *after, *char, times), SelectionGoal::None, ), NextLineStart => (next_line_start(map, point, times), SelectionGoal::None), @@ -793,44 +793,55 @@ fn find_forward( map: &DisplaySnapshot, from: DisplayPoint, before: bool, - target: Arc, + target: char, times: usize, ) -> DisplayPoint { - map.find_while(from, target.as_ref(), |ch, _| ch != '\n') - .skip_while(|found_at| found_at == &from) - .nth(times - 1) - .map(|mut found| { - if before { - *found.column_mut() -= 1; - found = map.clip_point(found, Bias::Right); - found - } else { - found - } - }) - .unwrap_or(from) + let mut to = from; + let mut found = false; + + for _ in 0..times { + found = false; + to = find_boundary(map, to, FindRange::SingleLine, |_, right| { + found = right == target; + found + }); + } + + if found { + if before && to.column() > 0 { + *to.column_mut() -= 1; + map.clip_point(to, Bias::Left) + } else { + to + } + } else { + from + } } fn find_backward( map: &DisplaySnapshot, from: DisplayPoint, after: bool, - target: Arc, + target: char, times: usize, ) -> DisplayPoint { - map.reverse_find_while(from, target.as_ref(), |ch, _| ch != '\n') - .skip_while(|found_at| found_at == &from) - .nth(times - 1) - .map(|mut found| { - if after { - *found.column_mut() += 1; - found = map.clip_point(found, Bias::Left); - found - } else { - found - } - }) - .unwrap_or(from) + let mut to = from; + + for _ in 0..times { + to = find_preceding_boundary(map, to, FindRange::SingleLine, |_, right| right == target); + } + + if map.buffer_snapshot.chars_at(to.to_point(map)).next() == Some(target) { + if after { + *to.column_mut() += 1; + map.clip_point(to, Bias::Right) + } else { + to + } + } else { + from + } } fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index ce1e4c3d6d50dfddda933be1fd3fe2f6bd448bbf..e452fb65ae554461f1ead3a152f8b846a2630f61 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -780,6 +780,7 @@ mod test { #[gpui::test] async fn test_f_and_t(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; + for count in 1..=3 { let test_case = indoc! {" ˇaaaˇbˇ ˇbˇ ˇbˇbˇ aˇaaˇbaaa diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index c6a212d77f1d91118defa0b3c8097f6c38f112ab..54bbe6c56ea468932e08899bfcad8ed602c545b9 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -449,6 +449,13 @@ async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) { fourteen char "}) .await; + cx.simulate_shared_keystrokes(["j", "shift-f", "e", "f", "r"]) + .await; + cx.assert_shared_state(indoc! {" + fourteen• + fourteen chaˇr + "}) + .await; } #[gpui::test] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index da1c634682f1511c197cb32687502422606cf387..be3d04d3ee0c28d3238c4627f793103d8f62a0c2 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -295,14 +295,20 @@ impl Vim { match Vim::read(cx).active_operator() { Some(Operator::FindForward { before }) => { - let find = Motion::FindForward { before, text }; + let find = Motion::FindForward { + before, + char: text.chars().next().unwrap(), + }; Vim::update(cx, |vim, _| { vim.workspace_state.last_find = Some(find.clone()) }); motion::motion(find, cx) } Some(Operator::FindBackward { after }) => { - let find = Motion::FindBackward { after, text }; + let find = Motion::FindBackward { + after, + char: text.chars().next().unwrap(), + }; Vim::update(cx, |vim, _| { vim.workspace_state.last_find = Some(find.clone()) }); diff --git a/crates/vim/test_data/test_wrapped_lines.json b/crates/vim/test_data/test_wrapped_lines.json index 1fbfc935d93824b376c06e5fa2eaa980e530df6f..e5b5d0eac0b21ace460f2c09cfeb0b5861dcd951 100644 --- a/crates/vim/test_data/test_wrapped_lines.json +++ b/crates/vim/test_data/test_wrapped_lines.json @@ -53,3 +53,9 @@ {"Key":"i"} {"Key":"w"} {"Get":{"state":"fourteenˇ \nfourteen char\n","mode":"Normal"}} +{"Key":"j"} +{"Key":"shift-f"} +{"Key":"e"} +{"Key":"f"} +{"Key":"r"} +{"Get":{"state":"fourteen \nfourteen chaˇr\n","mode":"Normal"}} From f4a9d3f26979335cb65710f2340a9e554f2a4622 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 8 Sep 2023 12:41:32 -0400 Subject: [PATCH 08/15] Add tooltip to language selector --- crates/language_selector/src/active_buffer_language.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 5ffcb13fbafa7be008346f97bcfb1b2041b4244b..01333c1ffb8310302159b1f1306deaefc34bb157 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -52,6 +52,7 @@ impl View for ActiveBufferLanguage { } else { "Unknown".to_string() }; + let theme = theme::current(cx).clone(); MouseEventHandler::new::(0, cx, |state, cx| { let theme = &theme::current(cx).workspace.status_bar; @@ -68,6 +69,7 @@ impl View for ActiveBufferLanguage { }); } }) + .with_tooltip::(0, "Select Language", None, theme.tooltip.clone(), cx) .into_any() } else { Empty::new().into_any() From a0701777d5b2d8762c5f84ff29d6a19e0f16e3b3 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 8 Sep 2023 12:44:49 -0400 Subject: [PATCH 09/15] Make tooltip title case to match other tooltips --- crates/collab_ui/src/collab_titlebar_item.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 95b98689374250c824b3c7e84d16aac45d7f6a11..6c34b7021ee37b51843015642f6f5f05166868ed 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -771,7 +771,7 @@ impl CollabTitlebarItem { }) .with_tooltip::( 0, - "Toggle user menu".to_owned(), + "Toggle User Menu".to_owned(), Some(Box::new(ToggleUserMenu)), tooltip, cx, From 88dae22e3eedbc1b6c42209b5a985705345cfbed Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 8 Sep 2023 10:56:26 -0600 Subject: [PATCH 10/15] Don't replay ShowCharacterPalette --- crates/vim/src/normal/repeat.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index a29141941358ae925bd32902721c2249c4f8f404..1a7c789aad9a680eddef208eed182b5b237ca286 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -4,11 +4,19 @@ use crate::{ visual::visual_motion, Vim, }; -use gpui::{actions, AppContext}; +use gpui::{actions, Action, AppContext}; use workspace::Workspace; actions!(vim, [Repeat, EndRepeat,]); +fn should_replay(action: &Box) -> bool { + // skip so that we don't leave the character palette open + if editor::ShowCharacterPalette.id() == action.id() { + return false; + } + true +} + pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| { Vim::update(cx, |vim, cx| { @@ -118,9 +126,15 @@ pub(crate) fn init(cx: &mut AppContext) { .spawn(move |mut cx| async move { for action in actions { match action { - ReplayableAction::Action(action) => window - .dispatch_action(editor.id(), action.as_ref(), &mut cx) - .ok_or_else(|| anyhow::anyhow!("window was closed")), + 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, From ba1c350dad27cc73e0887c243cfc9c7ac2a7ce5b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 8 Sep 2023 13:55:13 -0600 Subject: [PATCH 11/15] vim: Add ZZ and ZQ The major change here is a refactoring to allow controling the save behaviour when closing items, which is pre-work needed for vim command palette. For zed-industries/community#1868 --- assets/keymaps/vim.json | 12 ++ crates/collab/src/tests/integration_tests.rs | 4 +- crates/file_finder/src/file_finder.rs | 9 +- crates/terminal_view/src/terminal_view.rs | 7 +- crates/workspace/src/item.rs | 10 +- crates/workspace/src/pane.rs | 198 ++++++++++++++----- crates/workspace/src/workspace.rs | 46 +++-- crates/zed/src/menus.rs | 7 +- crates/zed/src/zed.rs | 54 +++-- 9 files changed, 258 insertions(+), 89 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 45891adee66385bc748439294bc51cd533eab2ef..b47907783ea1d8397b84a5bbc47b8924a19c285f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -198,6 +198,18 @@ "z c": "editor::Fold", "z o": "editor::UnfoldLines", "z f": "editor::FoldSelectedRanges", + "shift-z shift-q": [ + "pane::CloseActiveItem", + { + "saveBehavior": "dontSave" + } + ], + "shift-z shift-z": [ + "pane::CloseActiveItem", + { + "saveBehavior": "promptOnConflict" + } + ], // Count support "1": [ "vim::Number", diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 8121b0ac91d5021e2830236c1694a24a20cff3b5..405956e5dbd97947fba867c744cb2e109b1d1ec0 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1530,7 +1530,9 @@ async fn test_host_disconnect( // Ensure client B is not prompted to save edits when closing window after disconnecting. let can_close = workspace_b - .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx)) + .update(cx_b, |workspace, cx| { + workspace.prepare_to_close(true, workspace::SaveBehavior::PromptOnWrite, cx) + }) .await .unwrap(); assert!(can_close); diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 523d6e8a5caeb9edea4c990237263c66e7d384e4..c2d8cc52b26f7483a40982cac04a96c4c8d92bb0 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1528,8 +1528,13 @@ mod tests { let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); active_pane .update(cx, |pane, cx| { - pane.close_active_item(&workspace::CloseActiveItem, cx) - .unwrap() + pane.close_active_item( + &workspace::CloseActiveItem { + save_behavior: None, + }, + cx, + ) + .unwrap() }) .await .unwrap(); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 104d181a7b9de60460bafa0f12abac51f4ac22e2..a12f9d3c3c0447b82fde8db18b28d022f1d0cf4c 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -283,7 +283,12 @@ impl TerminalView { pub fn deploy_context_menu(&mut self, position: Vector2F, cx: &mut ViewContext) { let menu_entries = vec![ ContextMenuItem::action("Clear", Clear), - ContextMenuItem::action("Close", pane::CloseActiveItem), + ContextMenuItem::action( + "Close", + pane::CloseActiveItem { + save_behavior: None, + }, + ), ]; self.context_menu.update(cx, |menu, cx| { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 4e24c831f4161e6f02478e533a44451f72e7694c..ea747b3a364720c653f91ce7b8bc609750509fe3 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -474,8 +474,14 @@ impl ItemHandle for ViewHandle { for item_event in T::to_item_events(event).into_iter() { match item_event { ItemEvent::CloseItem => { - pane.update(cx, |pane, cx| pane.close_item_by_id(item.id(), cx)) - .detach_and_log_err(cx); + pane.update(cx, |pane, cx| { + pane.close_item_by_id( + item.id(), + crate::SaveBehavior::PromptOnWrite, + cx, + ) + }) + .detach_and_log_err(cx); return; } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index fe3173ac9b92dbe27a4b4c3d36987c8c6b0ab634..eb78e30e98c3a4322d0d2ebd085d8f3b470bad6f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -43,6 +43,19 @@ use std::{ }; use theme::{Theme, ThemeSettings}; +#[derive(PartialEq, Clone, Copy, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub enum SaveBehavior { + /// ask before overwriting conflicting files (used by default with %s) + PromptOnConflict, + /// ask before writing any file that wouldn't be auto-saved (used by default with %w) + PromptOnWrite, + /// never prompt, write on conflict (used with vim's :w!) + SilentlyOverwrite, + /// skip all save-related behaviour (used with vim's :cq) + DontSave, +} + #[derive(Clone, Deserialize, PartialEq)] pub struct ActivateItem(pub usize); @@ -64,13 +77,17 @@ pub struct CloseItemsToTheRightById { pub pane: WeakViewHandle, } +#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +pub struct CloseActiveItem { + pub save_behavior: Option, +} + actions!( pane, [ ActivatePrevItem, ActivateNextItem, ActivateLastItem, - CloseActiveItem, CloseInactiveItems, CloseCleanItems, CloseItemsToTheLeft, @@ -86,7 +103,7 @@ actions!( ] ); -impl_actions!(pane, [ActivateItem]); +impl_actions!(pane, [ActivateItem, CloseActiveItem]); const MAX_NAVIGATION_HISTORY_LEN: usize = 1024; @@ -696,22 +713,29 @@ impl Pane { pub fn close_active_item( &mut self, - _: &CloseActiveItem, + action: &CloseActiveItem, cx: &mut ViewContext, ) -> Option>> { if self.items.is_empty() { return None; } let active_item_id = self.items[self.active_item_index].id(); - Some(self.close_item_by_id(active_item_id, cx)) + Some(self.close_item_by_id( + active_item_id, + action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite), + cx, + )) } pub fn close_item_by_id( &mut self, item_id_to_close: usize, + save_behavior: SaveBehavior, cx: &mut ViewContext, ) -> Task> { - self.close_items(cx, move |view_id| view_id == item_id_to_close) + self.close_items(cx, save_behavior, move |view_id| { + view_id == item_id_to_close + }) } pub fn close_inactive_items( @@ -724,7 +748,11 @@ impl Pane { } let active_item_id = self.items[self.active_item_index].id(); - Some(self.close_items(cx, move |item_id| item_id != active_item_id)) + Some( + self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + item_id != active_item_id + }), + ) } pub fn close_clean_items( @@ -737,7 +765,11 @@ impl Pane { .filter(|item| !item.is_dirty(cx)) .map(|item| item.id()) .collect(); - Some(self.close_items(cx, move |item_id| item_ids.contains(&item_id))) + Some( + self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + item_ids.contains(&item_id) + }), + ) } pub fn close_items_to_the_left( @@ -762,7 +794,9 @@ impl Pane { .take_while(|item| item.id() != item_id) .map(|item| item.id()) .collect(); - self.close_items(cx, move |item_id| item_ids.contains(&item_id)) + self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + item_ids.contains(&item_id) + }) } pub fn close_items_to_the_right( @@ -788,7 +822,9 @@ impl Pane { .take_while(|item| item.id() != item_id) .map(|item| item.id()) .collect(); - self.close_items(cx, move |item_id| item_ids.contains(&item_id)) + self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + item_ids.contains(&item_id) + }) } pub fn close_all_items( @@ -800,12 +836,13 @@ impl Pane { return None; } - Some(self.close_items(cx, move |_| true)) + Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true)) } pub fn close_items( &mut self, cx: &mut ViewContext, + save_behavior: SaveBehavior, should_close: impl 'static + Fn(usize) -> bool, ) -> Task> { // Find the items to close. @@ -858,8 +895,15 @@ impl Pane { .any(|id| saved_project_items_ids.insert(*id)); if should_save - && !Self::save_item(project.clone(), &pane, item_ix, &*item, true, &mut cx) - .await? + && !Self::save_item( + project.clone(), + &pane, + item_ix, + &*item, + save_behavior, + &mut cx, + ) + .await? { break; } @@ -954,13 +998,17 @@ impl Pane { pane: &WeakViewHandle, item_ix: usize, item: &dyn ItemHandle, - should_prompt_for_save: bool, + save_behavior: SaveBehavior, cx: &mut AsyncAppContext, ) -> Result { const CONFLICT_MESSAGE: &str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; const DIRTY_MESSAGE: &str = "This file contains unsaved edits. Do you want to save it?"; + if save_behavior == SaveBehavior::DontSave { + return Ok(true); + } + let (has_conflict, is_dirty, can_save, is_singleton) = cx.read(|cx| { ( item.has_conflict(cx), @@ -971,18 +1019,22 @@ impl Pane { }); if has_conflict && can_save { - let mut answer = pane.update(cx, |pane, cx| { - pane.activate_item(item_ix, true, true, cx); - cx.prompt( - PromptLevel::Warning, - CONFLICT_MESSAGE, - &["Overwrite", "Discard", "Cancel"], - ) - })?; - match answer.next().await { - Some(0) => pane.update(cx, |_, cx| item.save(project, cx))?.await?, - Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?, - _ => return Ok(false), + if save_behavior == SaveBehavior::SilentlyOverwrite { + pane.update(cx, |_, cx| item.save(project, cx))?.await?; + } else { + let mut answer = pane.update(cx, |pane, cx| { + pane.activate_item(item_ix, true, true, cx); + cx.prompt( + PromptLevel::Warning, + CONFLICT_MESSAGE, + &["Overwrite", "Discard", "Cancel"], + ) + })?; + match answer.next().await { + Some(0) => pane.update(cx, |_, cx| item.save(project, cx))?.await?, + Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?, + _ => return Ok(false), + } } } else if is_dirty && (can_save || is_singleton) { let will_autosave = cx.read(|cx| { @@ -991,7 +1043,7 @@ impl Pane { AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange ) && Self::can_autosave_item(&*item, cx) }); - let should_save = if should_prompt_for_save && !will_autosave { + let should_save = if save_behavior == SaveBehavior::PromptOnWrite && !will_autosave { let mut answer = pane.update(cx, |pane, cx| { pane.activate_item(item_ix, true, true, cx); cx.prompt( @@ -1113,7 +1165,12 @@ impl Pane { AnchorCorner::TopLeft, if is_active_item { vec![ - ContextMenuItem::action("Close Active Item", CloseActiveItem), + ContextMenuItem::action( + "Close Active Item", + CloseActiveItem { + save_behavior: None, + }, + ), ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), ContextMenuItem::action("Close Clean Items", CloseCleanItems), ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft), @@ -1128,8 +1185,12 @@ impl Pane { move |cx| { if let Some(pane) = pane.upgrade(cx) { pane.update(cx, |pane, cx| { - pane.close_item_by_id(target_item_id, cx) - .detach_and_log_err(cx); + pane.close_item_by_id( + target_item_id, + SaveBehavior::PromptOnWrite, + cx, + ) + .detach_and_log_err(cx); }) } } @@ -1278,7 +1339,12 @@ impl Pane { .on_click(MouseButton::Middle, { let item_id = item.id(); move |_, pane, cx| { - pane.close_item_by_id(item_id, cx).detach_and_log_err(cx); + pane.close_item_by_id( + item_id, + SaveBehavior::PromptOnWrite, + cx, + ) + .detach_and_log_err(cx); } }) .on_down( @@ -1486,7 +1552,8 @@ impl Pane { cx.window_context().defer(move |cx| { if let Some(pane) = pane.upgrade(cx) { pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, cx).detach_and_log_err(cx); + pane.close_item_by_id(item_id, SaveBehavior::PromptOnWrite, cx) + .detach_and_log_err(cx); }); } }); @@ -2089,7 +2156,14 @@ mod tests { let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); pane.update(cx, |pane, cx| { - assert!(pane.close_active_item(&CloseActiveItem, cx).is_none()) + assert!(pane + .close_active_item( + &CloseActiveItem { + save_behavior: None + }, + cx + ) + .is_none()) }); } @@ -2339,31 +2413,59 @@ mod tests { add_labeled_item(&pane, "1", false, cx); assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx); - pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx)) - .unwrap() - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_behavior: None, + }, + cx, + ) + }) + .unwrap() + .await + .unwrap(); assert_item_labels(&pane, ["A", "B*", "C", "D"], cx); pane.update(cx, |pane, cx| pane.activate_item(3, false, false, cx)); assert_item_labels(&pane, ["A", "B", "C", "D*"], cx); - pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx)) - .unwrap() - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_behavior: None, + }, + cx, + ) + }) + .unwrap() + .await + .unwrap(); assert_item_labels(&pane, ["A", "B*", "C"], cx); - pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx)) - .unwrap() - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_behavior: None, + }, + cx, + ) + }) + .unwrap() + .await + .unwrap(); assert_item_labels(&pane, ["A", "C*"], cx); - pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx)) - .unwrap() - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_behavior: None, + }, + cx, + ) + }) + .unwrap() + .await + .unwrap(); assert_item_labels(&pane, ["A*"], cx); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index be8148256d0b0f294bbeeafbcfb59e47ec4862d7..abbed1fcf0546e7960f4b58de0929616f9e6628b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1258,7 +1258,7 @@ impl Workspace { cx: &mut ViewContext, ) -> Option>> { let window = cx.window(); - let prepare = self.prepare_to_close(false, cx); + let prepare = self.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx); Some(cx.spawn(|_, mut cx| async move { if prepare.await? { window.remove(&mut cx); @@ -1270,6 +1270,7 @@ impl Workspace { pub fn prepare_to_close( &mut self, quitting: bool, + save_behavior: SaveBehavior, cx: &mut ViewContext, ) -> Task> { let active_call = self.active_call().cloned(); @@ -1308,13 +1309,15 @@ impl Workspace { } Ok(this - .update(&mut cx, |this, cx| this.save_all_internal(true, cx))? + .update(&mut cx, |this, cx| { + this.save_all_internal(save_behavior, cx) + })? .await?) }) } fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext) -> Option>> { - let save_all = self.save_all_internal(false, cx); + let save_all = self.save_all_internal(SaveBehavior::PromptOnConflict, cx); Some(cx.foreground().spawn(async move { save_all.await?; Ok(()) @@ -1323,7 +1326,7 @@ impl Workspace { fn save_all_internal( &mut self, - should_prompt_to_save: bool, + save_behaviour: SaveBehavior, cx: &mut ViewContext, ) -> Task> { if self.project.read(cx).is_read_only() { @@ -1358,7 +1361,7 @@ impl Workspace { &pane, ix, &*item, - should_prompt_to_save, + save_behaviour, &mut cx, ) .await? @@ -1404,7 +1407,7 @@ impl Workspace { let close_task = if is_remote || has_worktree || has_dirty_items { None } else { - Some(self.prepare_to_close(false, cx)) + Some(self.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx)) }; let app_state = self.app_state.clone(); @@ -4099,7 +4102,7 @@ pub fn restart(_: &Restart, cx: &mut AppContext) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { if let Some(should_close) = window.update_root(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, cx) + workspace.prepare_to_close(true, SaveBehavior::PromptOnWrite, cx) }) { if !should_close.await? { return Ok(()); @@ -4289,7 +4292,9 @@ mod tests { // When there are no dirty items, there's nothing to do. let item1 = window.add_view(cx, |_| TestItem::new()); workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx)); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); + let task = workspace.update(cx, |w, cx| { + w.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx) + }); assert!(task.await.unwrap()); // When there are dirty untitled items, prompt to save each one. If the user @@ -4304,7 +4309,9 @@ mod tests { w.add_item(Box::new(item2.clone()), cx); w.add_item(Box::new(item3.clone()), cx); }); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); + let task = workspace.update(cx, |w, cx| { + w.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx) + }); cx.foreground().run_until_parked(); window.simulate_prompt_answer(2, cx); // cancel cx.foreground().run_until_parked(); @@ -4358,7 +4365,9 @@ mod tests { let item1_id = item1.id(); let item3_id = item3.id(); let item4_id = item4.id(); - pane.close_items(cx, move |id| [item1_id, item3_id, item4_id].contains(&id)) + pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| { + [item1_id, item3_id, item4_id].contains(&id) + }) }); cx.foreground().run_until_parked(); @@ -4493,7 +4502,9 @@ mod tests { // once for project entry 0, and once for project entry 2. After those two // prompts, the task should complete. - let close = left_pane.update(cx, |pane, cx| pane.close_items(cx, |_| true)); + let close = left_pane.update(cx, |pane, cx| { + pane.close_items(cx, SaveBehavior::PromptOnWrite, move |_| true) + }); cx.foreground().run_until_parked(); left_pane.read_with(cx, |pane, cx| { assert_eq!( @@ -4609,9 +4620,11 @@ mod tests { item.is_dirty = true; }); - pane.update(cx, |pane, cx| pane.close_items(cx, move |id| id == item_id)) - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id) + }) + .await + .unwrap(); assert!(!window.has_pending_prompt(cx)); item.read_with(cx, |item, _| assert_eq!(item.save_count, 5)); @@ -4630,8 +4643,9 @@ mod tests { item.read_with(cx, |item, _| assert_eq!(item.save_count, 5)); // Ensure autosave is prevented for deleted files also when closing the buffer. - let _close_items = - pane.update(cx, |pane, cx| pane.close_items(cx, move |id| id == item_id)); + let _close_items = pane.update(cx, |pane, cx| { + pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id) + }); deterministic.run_until_parked(); assert!(window.has_pending_prompt(cx)); item.read_with(cx, |item, _| assert_eq!(item.save_count, 5)); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 22a260b588e6ae002a6750e94179e5e118ee52a6..6b5f7b3a35868ffc20e9a26bc6b694bbe2a9ecba 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -41,7 +41,12 @@ pub fn menus() -> Vec> { MenuItem::action("Save", workspace::Save), MenuItem::action("Save As…", workspace::SaveAs), MenuItem::action("Save All", workspace::SaveAll), - MenuItem::action("Close Editor", workspace::CloseActiveItem), + MenuItem::action( + "Close Editor", + workspace::CloseActiveItem { + save_behavior: None, + }, + ), MenuItem::action("Close Window", workspace::CloseWindow), ], }, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 424bce60f22c804d90df9ab8814992f472b10190..c00bb7ee0d654e625e218737279075ba5363a24d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -438,7 +438,7 @@ fn quit(_: &Quit, cx: &mut gpui::AppContext) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { if let Some(should_close) = window.update_root(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, cx) + workspace.prepare_to_close(true, workspace::SaveBehavior::PromptOnWrite, cx) }) { if !should_close.await? { return Ok(()); @@ -733,7 +733,7 @@ mod tests { use theme::{ThemeRegistry, ThemeSettings}; use workspace::{ item::{Item, ItemHandle}, - open_new, open_paths, pane, NewFile, SplitDirection, WorkspaceHandle, + open_new, open_paths, pane, NewFile, SaveBehavior, SplitDirection, WorkspaceHandle, }; #[gpui::test] @@ -1495,7 +1495,12 @@ mod tests { pane2_item.downcast::().unwrap().downgrade() }); - cx.dispatch_action(window.into(), workspace::CloseActiveItem); + cx.dispatch_action( + window.into(), + workspace::CloseActiveItem { + save_behavior: None, + }, + ); cx.foreground().run_until_parked(); workspace.read_with(cx, |workspace, _| { @@ -1503,7 +1508,12 @@ mod tests { assert_eq!(workspace.active_pane(), &pane_1); }); - cx.dispatch_action(window.into(), workspace::CloseActiveItem); + cx.dispatch_action( + window.into(), + workspace::CloseActiveItem { + save_behavior: None, + }, + ); cx.foreground().run_until_parked(); window.simulate_prompt_answer(1, cx); cx.foreground().run_until_parked(); @@ -1661,7 +1671,7 @@ mod tests { pane.update(cx, |pane, cx| { let editor3_id = editor3.id(); drop(editor3); - pane.close_item_by_id(editor3_id, cx) + pane.close_item_by_id(editor3_id, SaveBehavior::PromptOnWrite, cx) }) .await .unwrap(); @@ -1696,7 +1706,7 @@ mod tests { pane.update(cx, |pane, cx| { let editor2_id = editor2.id(); drop(editor2); - pane.close_item_by_id(editor2_id, cx) + pane.close_item_by_id(editor2_id, SaveBehavior::PromptOnWrite, cx) }) .await .unwrap(); @@ -1852,24 +1862,32 @@ mod tests { assert_eq!(active_path(&workspace, cx), Some(file4.clone())); // Close all the pane items in some arbitrary order. - pane.update(cx, |pane, cx| pane.close_item_by_id(file1_item_id, cx)) - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file1_item_id, SaveBehavior::PromptOnWrite, cx) + }) + .await + .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - pane.update(cx, |pane, cx| pane.close_item_by_id(file4_item_id, cx)) - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file4_item_id, SaveBehavior::PromptOnWrite, cx) + }) + .await + .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - pane.update(cx, |pane, cx| pane.close_item_by_id(file2_item_id, cx)) - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file2_item_id, SaveBehavior::PromptOnWrite, cx) + }) + .await + .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - pane.update(cx, |pane, cx| pane.close_item_by_id(file3_item_id, cx)) - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file3_item_id, SaveBehavior::PromptOnWrite, cx) + }) + .await + .unwrap(); assert_eq!(active_path(&workspace, cx), None); // Reopen all the closed items, ensuring they are reopened in the same order From 4c92172ccac088825c8470b986159d1694787da8 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 8 Sep 2023 16:49:50 -0600 Subject: [PATCH 12/15] Partially roll back refactoring --- crates/collab/src/tests/integration_tests.rs | 4 +--- crates/workspace/src/workspace.rs | 17 ++++++----------- crates/zed/src/zed.rs | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 405956e5dbd97947fba867c744cb2e109b1d1ec0..8121b0ac91d5021e2830236c1694a24a20cff3b5 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1530,9 +1530,7 @@ async fn test_host_disconnect( // Ensure client B is not prompted to save edits when closing window after disconnecting. let can_close = workspace_b - .update(cx_b, |workspace, cx| { - workspace.prepare_to_close(true, workspace::SaveBehavior::PromptOnWrite, cx) - }) + .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx)) .await .unwrap(); assert!(can_close); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index abbed1fcf0546e7960f4b58de0929616f9e6628b..f0cb94202665112484c6d9bf30a3897f787d559a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1258,7 +1258,7 @@ impl Workspace { cx: &mut ViewContext, ) -> Option>> { let window = cx.window(); - let prepare = self.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx); + let prepare = self.prepare_to_close(false, cx); Some(cx.spawn(|_, mut cx| async move { if prepare.await? { window.remove(&mut cx); @@ -1270,7 +1270,6 @@ impl Workspace { pub fn prepare_to_close( &mut self, quitting: bool, - save_behavior: SaveBehavior, cx: &mut ViewContext, ) -> Task> { let active_call = self.active_call().cloned(); @@ -1310,7 +1309,7 @@ impl Workspace { Ok(this .update(&mut cx, |this, cx| { - this.save_all_internal(save_behavior, cx) + this.save_all_internal(SaveBehavior::PromptOnWrite, cx) })? .await?) }) @@ -1407,7 +1406,7 @@ impl Workspace { let close_task = if is_remote || has_worktree || has_dirty_items { None } else { - Some(self.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx)) + Some(self.prepare_to_close(false, cx)) }; let app_state = self.app_state.clone(); @@ -4102,7 +4101,7 @@ pub fn restart(_: &Restart, cx: &mut AppContext) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { if let Some(should_close) = window.update_root(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, SaveBehavior::PromptOnWrite, cx) + workspace.prepare_to_close(true, cx) }) { if !should_close.await? { return Ok(()); @@ -4292,9 +4291,7 @@ mod tests { // When there are no dirty items, there's nothing to do. let item1 = window.add_view(cx, |_| TestItem::new()); workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx)); - let task = workspace.update(cx, |w, cx| { - w.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx) - }); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); assert!(task.await.unwrap()); // When there are dirty untitled items, prompt to save each one. If the user @@ -4309,9 +4306,7 @@ mod tests { w.add_item(Box::new(item2.clone()), cx); w.add_item(Box::new(item3.clone()), cx); }); - let task = workspace.update(cx, |w, cx| { - w.prepare_to_close(false, SaveBehavior::PromptOnWrite, cx) - }); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); cx.foreground().run_until_parked(); window.simulate_prompt_answer(2, cx); // cancel cx.foreground().run_until_parked(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c00bb7ee0d654e625e218737279075ba5363a24d..f12dc8a98bb108df5420eb769978a1587aa5b9c3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -438,7 +438,7 @@ fn quit(_: &Quit, cx: &mut gpui::AppContext) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { if let Some(should_close) = window.update_root(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, workspace::SaveBehavior::PromptOnWrite, cx) + workspace.prepare_to_close(true, cx) }) { if !should_close.await? { return Ok(()); From 7cc05c99c23bc161650565f2c2c389c86888ac8f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 8 Sep 2023 23:46:12 -0600 Subject: [PATCH 13/15] Update getting started Just ran through this again. --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8849f1aa73f8ef05e52d8a5aaa0fb8f3daa5ad63..72936e274677935dec2f517d263708661d297973 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,31 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea ### Dependencies -* Install [Postgres.app](https://postgresapp.com) and start it. +* Install Xcode from https://apps.apple.com/us/app/xcode/id497799835?mt=12, and accept the license: + ``` + sudo xcodebuild -license + ``` + +* Install homebrew, rust and node + ``` + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + brew install rust + brew install node + ``` + +* Ensure rust executables are in your $PATH + ``` + echo $HOME/.cargo/bin | sudo tee /etc/paths.d/10-rust + ``` + +* Install postgres and configure the database + ``` + brew install postgresql@15 + brew services start postgresql@15 + psql -c "CREATE ROLE postgres SUPERUSER LOGIN" postgres + psql -U postgres -c "CREATE DATABASE zed" + ``` + * Install the `LiveKit` server and the `foreman` process supervisor: ``` @@ -41,6 +65,17 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea GITHUB_TOKEN=<$token> script/bootstrap ``` +* Now try running zed with collaboration disabled: + ``` + cargo run + ``` + +### Common errors + +* `xcrun: error: unable to find utility "metal", not a developer tool or in PATH` + * You need to install Xcode and then run: `xcode-select --switch /Applications/Xcode.app/Contents/Developer` + * (see https://github.com/gfx-rs/gfx/issues/2309) + ### Testing against locally-running servers Start the web and collab servers: From ef03e206d625063c68954ca6717fa22f0fe7e3e2 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 5 Sep 2023 11:01:55 -0700 Subject: [PATCH 14/15] WIP: Add nushell support --- Cargo.lock | 10 +++ Cargo.toml | 1 + .../LiveKitBridge/Package.resolved | 4 +- crates/zed/Cargo.toml | 1 + crates/zed/src/languages.rs | 1 + crates/zed/src/languages/nushell.rs | 0 crates/zed/src/languages/nushell/config.toml | 9 +++ .../zed/src/languages/nushell/highlights.scm | 61 +++++++++++++++++++ 8 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 crates/zed/src/languages/nushell.rs create mode 100644 crates/zed/src/languages/nushell/config.toml create mode 100644 crates/zed/src/languages/nushell/highlights.scm diff --git a/Cargo.lock b/Cargo.lock index d4b7beba7bd35541a5dd53c450f21fdae25880e2..4bbe29742c2449585b6b94774d940f6b3d7dc70b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8406,6 +8406,15 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-nu" +version = "0.0.1" +source = "git+https://github.com/mikayla-maki/tree-sitter-nu/?rev=6cf9ee39ceb3da79501de7646b10e5e1da800ab8#6cf9ee39ceb3da79501de7646b10e5e1da800ab8" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-php" version = "0.19.1" @@ -9868,6 +9877,7 @@ dependencies = [ "tree-sitter-lua", "tree-sitter-markdown", "tree-sitter-nix", + "tree-sitter-nu", "tree-sitter-php", "tree-sitter-python", "tree-sitter-racket", diff --git a/Cargo.toml b/Cargo.toml index 5938ecb40240765844c1849662b082afeae07a3a..01f42c306219883950fa156121c073e384b48d82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,7 @@ tree-sitter-racket = { git = "https://github.com/zed-industries/tree-sitter-rack tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930"} tree-sitter-lua = "0.0.14" tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" } +tree-sitter-nu = { git = "https://github.com/mikayla-maki/tree-sitter-nu/", rev = "6cf9ee39ceb3da79501de7646b10e5e1da800ab8"} [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "35a6052fbcafc5e5fc0f9415b8652be7dcaf7222" } diff --git a/crates/live_kit_client/LiveKitBridge/Package.resolved b/crates/live_kit_client/LiveKitBridge/Package.resolved index 85ae0885652d9054356ca19b9be6d5fd8642d16d..b925bc8f0d5ef290993fa0d49adcf221dd3570f6 100644 --- a/crates/live_kit_client/LiveKitBridge/Package.resolved +++ b/crates/live_kit_client/LiveKitBridge/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/apple/swift-protobuf.git", "state": { "branch": null, - "revision": "0af9125c4eae12a4973fb66574c53a54962a9e1e", - "version": "1.21.0" + "revision": "ce20dc083ee485524b802669890291c0d8090170", + "version": "1.22.1" } } ] diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index e102a665191ee019531eac42d295d67965028e5c..1d014197e1e14262180763151c2649dd04158686 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -132,6 +132,7 @@ tree-sitter-racket.workspace = true tree-sitter-yaml.workspace = true tree-sitter-lua.workspace = true tree-sitter-nix.workspace = true +tree-sitter-nu.workspace = true url = "2.2" urlencoding = "2.1.2" diff --git a/crates/zed/src/languages.rs b/crates/zed/src/languages.rs index 3fbb5aa14f70bd0b295172104bdb607427367411..0b1fa750c084dd44a006cad258f4e7d11fc153f9 100644 --- a/crates/zed/src/languages.rs +++ b/crates/zed/src/languages.rs @@ -170,6 +170,7 @@ pub fn init(languages: Arc, node_runtime: Arc language("elm", tree_sitter_elm::language(), vec![]); language("glsl", tree_sitter_glsl::language(), vec![]); language("nix", tree_sitter_nix::language(), vec![]); + language("nu", tree_sitter_nu::language(), vec![]); } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/zed/src/languages/nushell.rs b/crates/zed/src/languages/nushell.rs new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/crates/zed/src/languages/nushell/config.toml b/crates/zed/src/languages/nushell/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..d382b0705aa32cf54624f4a38ad348e3a9502ad3 --- /dev/null +++ b/crates/zed/src/languages/nushell/config.toml @@ -0,0 +1,9 @@ +name = "Nu" +path_suffixes = ["nu"] +line_comment = "# " +autoclose_before = ";:.,=}])>` \n\t\"" +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, +] diff --git a/crates/zed/src/languages/nushell/highlights.scm b/crates/zed/src/languages/nushell/highlights.scm new file mode 100644 index 0000000000000000000000000000000000000000..b97ed9836e659a3822f845a188b103b6eca07895 --- /dev/null +++ b/crates/zed/src/languages/nushell/highlights.scm @@ -0,0 +1,61 @@ +(string) @string +(type) @type +(value_path) @variable +(comment) @comment + +(number_literal) @number +(range from: (number_literal) @number) +(range to: (number_literal) @number) + +(command cmd_name: (identifier) @function) +(function_definition func_name: (identifier) @function) + +[ + (variable_declaration name: (identifier)) + (parameter (identifier)) + (flag (flag_name)) + (flag (flag_shorthand_name)) + (record_entry entry_name: (identifier)) + (block_args block_param: (identifier)) +] @property +; (parameter (identifier) @variable.parameter) ; -- alternative highlighting group? + +(cmd_invocation) @embedded + + +((identifier) @constant + (.match? @constant "^[A-Z][A-Z\\d_]*$")) + +[ + "if" + "else" + "not" + "let" + "def" + "def-env" + "export" + "true" + "false" + "and" + "or" +] @keyword + +[ + ; "/" Not making / an operator may lead to better highlighting? + "$" + "|" + "+" + "-" + "*" + "=" + "!=" + "and" + "or" + "==" + ">" +] @operator + +["." + "," + ";" +] @delimiter From 2be34ef254f27ee22e3a5e586cc611db1732b629 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Sat, 9 Sep 2023 14:44:52 -0700 Subject: [PATCH 15/15] Add syntax highlighting for nu scripts --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/zed/src/languages/nu/brackets.scm | 4 + .../src/languages/{nushell => nu}/config.toml | 0 crates/zed/src/languages/nu/highlights.scm | 302 ++++++++++++++++++ crates/zed/src/languages/nu/indents.scm | 3 + crates/zed/src/languages/nushell.rs | 0 .../zed/src/languages/nushell/highlights.scm | 61 ---- 8 files changed, 311 insertions(+), 63 deletions(-) create mode 100644 crates/zed/src/languages/nu/brackets.scm rename crates/zed/src/languages/{nushell => nu}/config.toml (100%) create mode 100644 crates/zed/src/languages/nu/highlights.scm create mode 100644 crates/zed/src/languages/nu/indents.scm delete mode 100644 crates/zed/src/languages/nushell.rs delete mode 100644 crates/zed/src/languages/nushell/highlights.scm diff --git a/Cargo.lock b/Cargo.lock index 4bbe29742c2449585b6b94774d940f6b3d7dc70b..d1cd868eaac92ed3456f00394e7337bc8ff88a0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8409,7 +8409,7 @@ dependencies = [ [[package]] name = "tree-sitter-nu" version = "0.0.1" -source = "git+https://github.com/mikayla-maki/tree-sitter-nu/?rev=6cf9ee39ceb3da79501de7646b10e5e1da800ab8#6cf9ee39ceb3da79501de7646b10e5e1da800ab8" +source = "git+https://github.com/nushell/tree-sitter-nu?rev=786689b0562b9799ce53e824cb45a1a2a04dc673#786689b0562b9799ce53e824cb45a1a2a04dc673" dependencies = [ "cc", "tree-sitter", diff --git a/Cargo.toml b/Cargo.toml index 01f42c306219883950fa156121c073e384b48d82..c233c74b55816334347c88208624b294abd921f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,7 +141,7 @@ tree-sitter-racket = { git = "https://github.com/zed-industries/tree-sitter-rack tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930"} tree-sitter-lua = "0.0.14" tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" } -tree-sitter-nu = { git = "https://github.com/mikayla-maki/tree-sitter-nu/", rev = "6cf9ee39ceb3da79501de7646b10e5e1da800ab8"} +tree-sitter-nu = { git = "https://github.com/nushell/tree-sitter-nu", rev = "786689b0562b9799ce53e824cb45a1a2a04dc673"} [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "35a6052fbcafc5e5fc0f9415b8652be7dcaf7222" } diff --git a/crates/zed/src/languages/nu/brackets.scm b/crates/zed/src/languages/nu/brackets.scm new file mode 100644 index 0000000000000000000000000000000000000000..7ede7a61926f47e75000b99b8b1436b057fe8346 --- /dev/null +++ b/crates/zed/src/languages/nu/brackets.scm @@ -0,0 +1,4 @@ +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) +(parameter_pipes "|" @open "|" @close) diff --git a/crates/zed/src/languages/nushell/config.toml b/crates/zed/src/languages/nu/config.toml similarity index 100% rename from crates/zed/src/languages/nushell/config.toml rename to crates/zed/src/languages/nu/config.toml diff --git a/crates/zed/src/languages/nu/highlights.scm b/crates/zed/src/languages/nu/highlights.scm new file mode 100644 index 0000000000000000000000000000000000000000..97f46d3879c28ab77cc6b641abf7bd4260836a4a --- /dev/null +++ b/crates/zed/src/languages/nu/highlights.scm @@ -0,0 +1,302 @@ +;;; --- +;;; keywords +[ + "def" + "def-env" + "alias" + "export-env" + "export" + "extern" + "module" + + "let" + "let-env" + "mut" + "const" + + "hide-env" + + "source" + "source-env" + + "overlay" + "register" + + "loop" + "while" + "error" + + "do" + "if" + "else" + "try" + "catch" + "match" + + "break" + "continue" + "return" + +] @keyword + +(hide_mod "hide" @keyword) +(decl_use "use" @keyword) + +(ctrl_for + "for" @keyword + "in" @keyword +) +(overlay_list "list" @keyword) +(overlay_hide "hide" @keyword) +(overlay_new "new" @keyword) +(overlay_use + "use" @keyword + "as" @keyword +) +(ctrl_error "make" @keyword) + +;;; --- +;;; literals +(val_number) @constant +(val_duration + unit: [ + "ns" "µs" "us" "ms" "sec" "min" "hr" "day" "wk" + ] @variable +) +(val_filesize + unit: [ + "b" "B" + + "kb" "kB" "Kb" "KB" + "mb" "mB" "Mb" "MB" + "gb" "gB" "Gb" "GB" + "tb" "tB" "Tb" "TB" + "pb" "pB" "Pb" "PB" + "eb" "eB" "Eb" "EB" + "zb" "zB" "Zb" "ZB" + + "kib" "kiB" "kIB" "kIb" "Kib" "KIb" "KIB" + "mib" "miB" "mIB" "mIb" "Mib" "MIb" "MIB" + "gib" "giB" "gIB" "gIb" "Gib" "GIb" "GIB" + "tib" "tiB" "tIB" "tIb" "Tib" "TIb" "TIB" + "pib" "piB" "pIB" "pIb" "Pib" "PIb" "PIB" + "eib" "eiB" "eIB" "eIb" "Eib" "EIb" "EIB" + "zib" "ziB" "zIB" "zIb" "Zib" "ZIb" "ZIB" + ] @variable +) +(val_binary + [ + "0b" + "0o" + "0x" + ] @constant + "[" @punctuation.bracket + digit: [ + "," @punctuation.delimiter + (hex_digit) @constant + ] + "]" @punctuation.bracket +) @constant +(val_bool) @constant.builtin +(val_nothing) @constant.builtin +(val_string) @string +(val_date) @constant +(inter_escape_sequence) @constant +(escape_sequence) @constant +(val_interpolated [ + "$\"" + "$\'" + "\"" + "\'" +] @string) +(unescaped_interpolated_content) @string +(escaped_interpolated_content) @string +(expr_interpolated ["(" ")"] @variable) + +;;; --- +;;; operators +(expr_binary [ + "+" + "-" + "*" + "/" + "mod" + "//" + "++" + "**" + "==" + "!=" + "<" + "<=" + ">" + ">=" + "=~" + "!~" + "and" + "or" + "xor" + "bit-or" + "bit-xor" + "bit-and" + "bit-shl" + "bit-shr" + "in" + "not-in" + "starts-with" + "ends-with" +] @operator) + +(expr_binary opr: ([ + "and" + "or" + "xor" + "bit-or" + "bit-xor" + "bit-and" + "bit-shl" + "bit-shr" + "in" + "not-in" + "starts-with" + "ends-with" +]) @keyword) + +(where_command [ + "+" + "-" + "*" + "/" + "mod" + "//" + "++" + "**" + "==" + "!=" + "<" + "<=" + ">" + ">=" + "=~" + "!~" + "and" + "or" + "xor" + "bit-or" + "bit-xor" + "bit-and" + "bit-shl" + "bit-shr" + "in" + "not-in" + "starts-with" + "ends-with" +] @operator) + +(assignment [ + "=" + "+=" + "-=" + "*=" + "/=" + "++=" +] @operator) + +(expr_unary ["not" "-"] @operator) + +(val_range [ + ".." + "..=" + "..<" +] @operator) + +["=>" "=" "|"] @operator + +[ + "o>" "out>" + "e>" "err>" + "e+o>" "err+out>" + "o+e>" "out+err>" +] @special + +;;; --- +;;; punctuation +[ + "," + ";" +] @punctuation.delimiter + +(param_short_flag "-" @punctuation.delimiter) +(param_long_flag ["--"] @punctuation.delimiter) +(long_flag ["--"] @punctuation.delimiter) +(param_rest "..." @punctuation.delimiter) +(param_type [":"] @punctuation.special) +(param_value ["="] @punctuation.special) +(param_cmd ["@"] @punctuation.special) +(param_opt ["?"] @punctuation.special) + +[ + "(" ")" + "{" "}" + "[" "]" +] @punctuation.bracket + +(val_record + (record_entry ":" @punctuation.delimiter)) +;;; --- +;;; identifiers +(param_rest + name: (_) @variable) +(param_opt + name: (_) @variable) +(parameter + param_name: (_) @variable) +(param_cmd + (cmd_identifier) @string) +(param_long_flag) @variable +(param_short_flag) @variable + +(short_flag) @variable +(long_flag) @variable + +(scope_pattern [(wild_card) @function]) + +(cmd_identifier) @function + +(command + "^" @punctuation.delimiter + head: (_) @function +) + +"where" @function + +(path + ["." "?"] @punctuation.delimiter +) @variable + +(val_variable + "$" @operator + [ + (identifier) @variable + "in" @type.builtin + "nu" @type.builtin + "env" @type.builtin + "nothing" @type.builtin + ] ; If we have a special styling, use it here +) +;;; --- +;;; types +(flat_type) @type.builtin +(list_type + "list" @type + ["<" ">"] @punctuation.bracket +) +(collection_type + ["record" "table"] @type + "<" @punctuation.bracket + key: (_) @variable + ["," ":"] @punctuation.delimiter + ">" @punctuation.bracket +) + +(shebang) @comment +(comment) @comment diff --git a/crates/zed/src/languages/nu/indents.scm b/crates/zed/src/languages/nu/indents.scm new file mode 100644 index 0000000000000000000000000000000000000000..112b414aa45f277138d0c681851129a608ee96e0 --- /dev/null +++ b/crates/zed/src/languages/nu/indents.scm @@ -0,0 +1,3 @@ +(_ "[" "]" @end) @indent +(_ "{" "}" @end) @indent +(_ "(" ")" @end) @indent diff --git a/crates/zed/src/languages/nushell.rs b/crates/zed/src/languages/nushell.rs deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/crates/zed/src/languages/nushell/highlights.scm b/crates/zed/src/languages/nushell/highlights.scm deleted file mode 100644 index b97ed9836e659a3822f845a188b103b6eca07895..0000000000000000000000000000000000000000 --- a/crates/zed/src/languages/nushell/highlights.scm +++ /dev/null @@ -1,61 +0,0 @@ -(string) @string -(type) @type -(value_path) @variable -(comment) @comment - -(number_literal) @number -(range from: (number_literal) @number) -(range to: (number_literal) @number) - -(command cmd_name: (identifier) @function) -(function_definition func_name: (identifier) @function) - -[ - (variable_declaration name: (identifier)) - (parameter (identifier)) - (flag (flag_name)) - (flag (flag_shorthand_name)) - (record_entry entry_name: (identifier)) - (block_args block_param: (identifier)) -] @property -; (parameter (identifier) @variable.parameter) ; -- alternative highlighting group? - -(cmd_invocation) @embedded - - -((identifier) @constant - (.match? @constant "^[A-Z][A-Z\\d_]*$")) - -[ - "if" - "else" - "not" - "let" - "def" - "def-env" - "export" - "true" - "false" - "and" - "or" -] @keyword - -[ - ; "/" Not making / an operator may lead to better highlighting? - "$" - "|" - "+" - "-" - "*" - "=" - "!=" - "and" - "or" - "==" - ">" -] @operator - -["." - "," - ";" -] @delimiter