diff --git a/Cargo.lock b/Cargo.lock index 121e9a28dd..d1cd868eaa 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/nushell/tree-sitter-nu?rev=786689b0562b9799ce53e824cb45a1a2a04dc673#786689b0562b9799ce53e824cb45a1a2a04dc673" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-php" version = "0.19.1" @@ -8829,12 +8838,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", @@ -9866,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 5938ecb402..c233c74b55 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/nushell/tree-sitter-nu", rev = "786689b0562b9799ce53e824cb45a1a2a04dc673"} [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "35a6052fbcafc5e5fc0f9415b8652be7dcaf7222" } diff --git a/README.md b/README.md index 8849f1aa73..72936e2746 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: diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index da094ea7e4..b47907783e 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", @@ -316,6 +328,7 @@ { "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting", "bindings": { + ".": "vim::Repeat", "c": [ "vim::PushOperator", "Change" @@ -326,15 +339,12 @@ "Delete" ], "shift-d": "vim::DeleteToEndOfLine", - "shift-j": "editor::JoinLines", + "shift-j": "vim::JoinLines", "y": [ "vim::PushOperator", "Yank" ], - "i": [ - "vim::SwitchMode", - "Insert" - ], + "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", "a": "vim::InsertAfter", "shift-a": "vim::InsertEndOfLine", @@ -448,13 +458,12 @@ ], "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", + "shift-j": "vim::JoinLines", "r": [ "vim::PushOperator", "Replace" diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 95b9868937..6c34b7021e 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, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index e8e15a927e..f306692b5e 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/editor/src/editor.rs b/crates/editor/src/editor.rs index bdd29b04fa..50a382439a 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, @@ -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, ) { - 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) - }; + if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); + return; + } - if let Some(new_selected_ranges) = new_selected_ranges { - this.change_selections(None, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - } + self.transact(cx, |this, 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) + }; + + 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 74bd67e03a..f11639a770 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/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 523d6e8a5c..c2d8cc52b2 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/gpui/src/app/window.rs b/crates/gpui/src/app/window.rs index 4b8b0534d5..09744579a9 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/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 5ffcb13fba..01333c1ffb 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() diff --git a/crates/live_kit_client/LiveKitBridge/Package.resolved b/crates/live_kit_client/LiveKitBridge/Package.resolved index 85ae088565..b925bc8f0d 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/terminal_view/README.md b/crates/terminal_view/README.md index 019460067e..ca48f54542 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/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 104d181a7b..a12f9d3c3c 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/vim/Cargo.toml b/crates/vim/Cargo.toml index 2d394e3dcf..5d40032024 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 994a09aaf9..da5c7d46ed 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 537f6a15f1..9141a02ab3 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 }, - FindBackward { after: bool, text: Arc }, + FindForward { before: bool, char: char }, + FindBackward { after: bool, char: char }, NextLineStart, } @@ -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)] @@ -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 ce1e4c3d6d..d920abee90 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,6 +2,7 @@ mod case; mod change; mod delete; mod paste; +mod repeat; mod scroll; mod search; pub mod substitute; @@ -34,6 +35,7 @@ actions!( vim, [ InsertAfter, + InsertBefore, InsertFirstNonWhitespace, InsertEndOfLine, InsertLineAbove, @@ -44,32 +46,42 @@ actions!( DeleteToEndOfLine, Yank, ChangeCase, + JoinLines, ] ); pub fn init(cx: &mut AppContext) { + paste::init(cx); + repeat::init(cx); + scroll::init(cx); + search::init(cx); + substitute::init(cx); + cx.add_action(insert_after); + cx.add_action(insert_before); cx.add_action(insert_first_non_whitespace); cx.add_action(insert_end_of_line); cx.add_action(insert_line_above); cx.add_action(insert_line_below); cx.add_action(change_case); - substitute::init(cx); - search::init(cx); + cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { + 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(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(cx); let times = vim.pop_number_operator(cx); change_motion( vim, @@ -83,6 +95,7 @@ pub fn init(cx: &mut AppContext) { }); cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); let times = vim.pop_number_operator(cx); delete_motion( vim, @@ -94,8 +107,26 @@ pub fn init(cx: &mut AppContext) { ); }) }); - scroll::init(cx); - paste::init(cx); + cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| { + Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); + let mut times = vim.pop_number_operator(cx).unwrap_or(1); + 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(&Default::default(), cx) + } + }) + }) + }) + }) } pub fn normal_motion( @@ -151,6 +182,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(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -162,12 +194,20 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.start_recording(cx); + 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(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -184,6 +224,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(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -197,6 +238,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(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -229,6 +271,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(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -260,6 +303,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); @@ -780,6 +824,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/normal/case.rs b/crates/vim/src/normal/case.rs index 90967949bb..12fd8dbd2b 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(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(); @@ -21,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; @@ -96,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/delete.rs b/crates/vim/src/normal/delete.rs index 56fef78e1d..ae85acaab5 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 3c437f9177..dda8dea1e4 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(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 new file mode 100644 index 0000000000..1a7c789aad --- /dev/null +++ b/crates/vim/src/normal/repeat.rs @@ -0,0 +1,427 @@ +use crate::{ + motion::Motion, + state::{Mode, RecordedSelection, ReplayableAction}, + visual::visual_motion, + Vim, +}; +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| { + vim.workspace_state.replaying = false; + vim.update_active_editor(cx, |editor, _| { + editor.show_local_selections = true; + }); + vim.switch_mode(Mode::Normal, false, cx) + }); + }); + + cx.add_action(|_: &mut Workspace, _: &Repeat, cx| { + let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| { + let actions = vim.workspace_state.recorded_actions.clone(); + let Some(editor) = vim.active_editor.clone() else { + return None; + }; + let count = vim.pop_number_operator(cx); + + vim.workspace_state.replaying = true; + + let selection = vim.workspace_state.recorded_selection.clone(); + match selection { + RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::Visual, false, cx) + } + RecordedSelection::VisualLine { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualLine, false, cx) + } + RecordedSelection::VisualBlock { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualBlock, false, cx) + } + RecordedSelection::None => { + if let Some(count) = count { + vim.workspace_state.recorded_count = Some(count); + } + } + } + + if let Some(editor) = editor.upgrade(cx) { + editor.update(cx, |editor, _| { + editor.show_local_selections = false; + }) + } else { + return None; + } + + Some((actions, editor, selection)) + }) else { + return; + }; + + match selection { + RecordedSelection::SingleLine { cols } => { + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::Visual { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + visual_motion( + Motion::StartOfLine { + display_lines: false, + }, + None, + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::VisualBlock { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx); + } + } + RecordedSelection::VisualLine { rows } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + } + RecordedSelection::None => {} + } + + let window = cx.window(); + cx.app_context() + .spawn(move |mut cx| async move { + for action in actions { + match action { + ReplayableAction::Action(action) => { + if should_replay(&action) { + window + .dispatch_action(editor.id(), action.as_ref(), &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + } else { + Ok(()) + } + } + ReplayableAction::Insertion { + text, + utf16_range_to_replace, + } => editor.update(&mut cx, |editor, cx| { + editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) + }), + }? + } + window + .dispatch_action(editor.id(), &EndRepeat, &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + }) + .detach_and_log_err(cx); + }); +} + +#[cfg(test)] +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(deterministic: Arc, 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; + 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; + } + + #[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, + ); + } + + #[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 23b545abd8..d0dbb9e306 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 aacd3d26e0..7359178f0e 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; @@ -48,10 +50,61 @@ 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, pub last_find: Option, + + pub recording: bool, + pub stop_recording_after_next_action: bool, + pub replaying: bool, + pub recorded_count: Option, + pub recorded_actions: Vec, + pub recorded_selection: RecordedSelection, +} + +#[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.rs b/crates/vim/src/test.rs index c6a212d77f..9aa9fffc0a 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; @@ -449,6 +498,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/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 9b03739570..7cee320373 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 da1c634682..03a74d46ce 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -18,17 +18,19 @@ 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 std::sync::Arc; +use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState}; +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 + .recorded_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 + .recorded_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,71 @@ impl Vim { let editor = self.active_editor.clone()?.upgrade(cx)?; Some(editor.update(cx, update)) } + // ~, shift-j, x, shift-x, p + // shift-c, shift-d, shift-i, i, a, o, shift-o, s + // c, d + // r + + // TODO: shift-j? + // + pub fn start_recording(&mut self, cx: &mut WindowContext) { + if !self.workspace_state.replaying { + self.workspace_state.recording = true; + self.workspace_state.recorded_actions = Default::default(); + self.workspace_state.recorded_count = + if let Some(Operator::Number(number)) = self.active_operator() { + Some(number) + } else { + None + }; + + 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; + } + } + } + + 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, cx: &mut WindowContext) { + self.start_recording(cx); + self.stop_recording(); + } fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) { let state = self.state(); @@ -247,6 +353,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(cx) + }; self.update_state(|state| state.operator_stack.push(operator)); self.sync_vim_settings(cx); } @@ -272,6 +384,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); @@ -295,14 +413,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/src/visual.rs b/crates/vim/src/visual.rs index ee46a0d209..acd55a0954 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(cx); 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_change_case.json b/crates/vim/test_data/test_change_case.json index 1c0cad0b93..10eb93b227 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_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json new file mode 100644 index 0000000000..f1a1a3c138 --- /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"}} 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 0000000000..b4bc5c30e1 --- /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"}} 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 0000000000..cb83addcfb --- /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"}} diff --git a/crates/vim/test_data/test_wrapped_lines.json b/crates/vim/test_data/test_wrapped_lines.json index 1fbfc935d9..e5b5d0eac0 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"}} diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 4e24c831f4..ea747b3a36 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 fe3173ac9b..eb78e30e98 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 be8148256d..f0cb942026 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1308,13 +1308,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(SaveBehavior::PromptOnWrite, 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 +1325,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 +1360,7 @@ impl Workspace { &pane, ix, &*item, - should_prompt_to_save, + save_behaviour, &mut cx, ) .await? @@ -4358,7 +4360,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 +4497,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 +4615,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 +4638,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/Cargo.toml b/crates/zed/Cargo.toml index e102a66519..1d014197e1 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 3fbb5aa14f..0b1fa750c0 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/nu/brackets.scm b/crates/zed/src/languages/nu/brackets.scm new file mode 100644 index 0000000000..7ede7a6192 --- /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/nu/config.toml b/crates/zed/src/languages/nu/config.toml new file mode 100644 index 0000000000..d382b0705a --- /dev/null +++ b/crates/zed/src/languages/nu/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/nu/highlights.scm b/crates/zed/src/languages/nu/highlights.scm new file mode 100644 index 0000000000..97f46d3879 --- /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 0000000000..112b414aa4 --- /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/python.rs b/crates/zed/src/languages/python.rs index c1539e9590..c10d605a38 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 854eeb7e08..62bdddab5b 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; diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 22a260b588..6b5f7b3a35 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 424bce60f2..f12dc8a98b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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