use super::*; use crate::{ JoinLines, code_context_menus::CodeContextMenu, edit_prediction_tests::FakeEditPredictionDelegate, element::{StickyHeader, header_jump_data}, linked_editing_ranges::LinkedEditingRanges, mouse_context_menu::MenuPosition, runnables::RunnableTasks, scroll::scroll_amount::ScrollAmount, test::{ assert_text_with_selections, build_editor, editor_content_with_blocks, editor_lsp_test_context::{EditorLspTestContext, git_commit_lang}, editor_test_context::EditorTestContext, select_ranges, }, }; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind}; use collections::{HashMap, HashSet}; use fs::Fs as _; use futures::{StreamExt, channel::oneshot}; use gpui::{ BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, VisualTestContext, WindowBounds, WindowOptions, div, }; use indoc::{formatdoc, indoc}; use language::{ BracketPair, BracketPairConfig, Capability::{Read, ReadOnly, ReadWrite}, ContextLocation, ContextProvider, DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings, LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries, LanguageToolchainStore, Override, PLAIN_TEXT, Point, language_settings::{ CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent, LspInsertMode, }, tree_sitter_python, }; use language_settings::Formatter; use languages::{language, markdown_lang, rust_lang}; use lsp::{CompletionParams, DEFAULT_LSP_REQUEST_TIMEOUT}; use multi_buffer::{IndentGuide, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey}; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; use project::{ FakeFs, Project, ProjectPath, bookmark_store::{BookmarkStore, BookmarkStoreEvent, SerializedBookmark}, debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, project_settings::LspSettings, trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use serde_json::{self, json}; use settings::{ AllLanguageSettingsContent, DelayMs, EditorSettingsContent, GlobalLspSettingsContent, GoToDefinitionScrollStrategy, IndentGuideBackgroundColoring, IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent, SettingsContent, SettingsStore, }; use std::{ borrow::Cow, cmp::Ordering, sync::{Arc, atomic}, }; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{iter, sync::atomic::AtomicUsize}; use task::TaskVariables; use test::build_editor_with_project; use unindent::Unindent; use util::{ assert_set_eq, path, rel_path::rel_path, test::{TextRangeMarker, marked_text_ranges, marked_text_ranges_by, sample_text}, }; use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MultiWorkspace, NavigationEntry, OpenOptions, Pane, SplitDirection, ToolbarItemLocation, ViewId, Workspace, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec> { editor .selections .display_ranges(&editor.display_snapshot(cx)) } #[gpui::test] fn test_highlighted_display_rows_in_range(cx: &mut TestAppContext) { struct FirstHighlight; struct SecondHighlight; init_test(cx, |_| {}); let buffer = cx.new(|cx| language::Buffer::local(sample_text(8, 3, 'a'), cx)); let editor = cx.add_window(|window, cx| Editor::for_buffer(buffer, None, window, cx)); assert!( editor .update(cx, |editor, window, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); editor.highlight_rows::( buffer.anchor_before(Point::new(0, 0))..buffer.anchor_before(Point::new(6, 0)), |cx| cx.theme().colors().editor_background, RowHighlightOptions::default(), cx, ); editor.highlight_rows::( buffer.anchor_before(Point::new(0, 0))..buffer.anchor_before(Point::new(1, 0)), |cx| cx.theme().colors().editor_highlighted_line_background, RowHighlightOptions::default(), cx, ); editor.highlight_rows::( buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), |cx| cx.theme().colors().editor_highlighted_line_background, RowHighlightOptions::default(), cx, ); editor.highlight_rows::( buffer.anchor_before(Point::new(6, 0))..buffer.anchor_before(Point::new(7, 0)), |cx| cx.theme().colors().editor_highlighted_line_background, RowHighlightOptions::default(), cx, ); let display_row_range = DisplayRow(2)..DisplayRow(5); let expected = editor .highlighted_display_rows(window, cx) .into_iter() .filter(|(row, _)| display_row_range.contains(row)) .collect::>(); let snapshot = editor.snapshot(window, cx); let actual = editor.highlighted_display_rows_in_range( buffer.anchor_before(Point::new(2, 0))..buffer.anchor_before(Point::new(5, 0)), display_row_range, &snapshot.display_snapshot, cx, ); assert_eq!(actual, expected); let highlight_start = buffer.anchor_before(Point::new(2, 0)); let Some(block_id) = editor .insert_blocks( [BlockProperties { style: BlockStyle::Fixed, placement: BlockPlacement::Above(highlight_start), height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, }], None, cx, ) .into_iter() .next() else { panic!("expected an inserted block"); }; editor.highlight_rows::( highlight_start..buffer.anchor_before(Point::new(4, 0)), |cx| cx.theme().colors().editor_highlighted_line_background, RowHighlightOptions::default(), cx, ); let Some(block_row) = editor.row_for_block(block_id, cx) else { panic!("expected an inserted block row"); }; let display_row_range = block_row..block_row.next_row(); let expected = editor .highlighted_display_rows(window, cx) .into_iter() .filter(|(row, _)| display_row_range.contains(row)) .collect::>(); let snapshot = editor.snapshot(window, cx); let actual = editor.highlighted_display_rows_in_range( buffer.anchor_before(Point::new(1, 0))..highlight_start, display_row_range, &snapshot.display_snapshot, cx, ); assert!(!expected.is_empty()); assert_eq!(actual, expected); }) .is_ok() ); } #[cfg(any(test, feature = "test-support"))] pub mod property_test; #[gpui::test] fn test_edit_events(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer = cx.new(|cx| { let mut buffer = language::Buffer::local("123456", cx); buffer.set_group_interval(Duration::from_secs(1)); buffer }); let events = Rc::new(RefCell::new(Vec::new())); let editor1 = cx.add_window({ let events = events.clone(); |window, cx| { let entity = cx.entity(); cx.subscribe_in( &entity, window, move |_, _, event: &EditorEvent, _, _| match event { EditorEvent::Edited { .. } => events.borrow_mut().push(("editor1", "edited")), EditorEvent::BufferEdited => { events.borrow_mut().push(("editor1", "buffer edited")) } _ => {} }, ) .detach(); Editor::for_buffer(buffer.clone(), None, window, cx) } }); let editor2 = cx.add_window({ let events = events.clone(); |window, cx| { cx.subscribe_in( &cx.entity(), window, move |_, _, event: &EditorEvent, _, _| match event { EditorEvent::Edited { .. } => events.borrow_mut().push(("editor2", "edited")), EditorEvent::BufferEdited => { events.borrow_mut().push(("editor2", "buffer edited")) } _ => {} }, ) .detach(); Editor::for_buffer(buffer.clone(), None, window, cx) } }); assert_eq!(mem::take(&mut *events.borrow_mut()), []); // Mutating editor 1 will emit an `Edited` event only for that editor. _ = editor1.update(cx, |editor, window, cx| editor.insert("X", window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor1", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // Mutating editor 2 will emit an `Edited` event only for that editor. _ = editor2.update(cx, |editor, window, cx| editor.delete(&Delete, window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor2", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // Undoing on editor 1 will emit an `Edited` event only for that editor. _ = editor1.update(cx, |editor, window, cx| editor.undo(&Undo, window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor1", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // Redoing on editor 1 will emit an `Edited` event only for that editor. _ = editor1.update(cx, |editor, window, cx| editor.redo(&Redo, window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor1", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // Undoing on editor 2 will emit an `Edited` event only for that editor. _ = editor2.update(cx, |editor, window, cx| editor.undo(&Undo, window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor2", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // Redoing on editor 2 will emit an `Edited` event only for that editor. _ = editor2.update(cx, |editor, window, cx| editor.redo(&Redo, window, cx)); assert_eq!( mem::take(&mut *events.borrow_mut()), [ ("editor2", "edited"), ("editor1", "buffer edited"), ("editor2", "buffer edited"), ] ); // No event is emitted when the mutation is a no-op. _ = editor2.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); editor.backspace(&Backspace, window, cx); }); assert_eq!(mem::take(&mut *events.borrow_mut()), []); } #[gpui::test] fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut now = Instant::now(); let group_interval = Duration::from_millis(1); let buffer = cx.new(|cx| { let mut buf = language::Buffer::local("123456", cx); buf.set_group_interval(group_interval); buf }); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); _ = editor.update(cx, |editor, window, cx| { editor.start_transaction_at(now, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(4)]) }); editor.insert("cd", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cd56"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(4)..MultiBufferOffset(4)] ); editor.start_transaction_at(now, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(5)]) }); editor.insert("e", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cde6"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(5)..MultiBufferOffset(5)] ); now += group_interval + Duration::from_millis(1); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) }); // Simulate an edit in another editor buffer.update(cx, |buffer, cx| { buffer.start_transaction_at(now, cx); buffer.edit( [(MultiBufferOffset(0)..MultiBufferOffset(1), "a")], None, cx, ); buffer.edit( [(MultiBufferOffset(1)..MultiBufferOffset(1), "b")], None, cx, ); buffer.end_transaction_at(now, cx); }); assert_eq!(editor.text(cx), "ab2cde6"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(3)..MultiBufferOffset(3)] ); // Last transaction happened past the group interval in a different editor. // Undo it individually and don't restore selections. editor.undo(&Undo, window, cx); assert_eq!(editor.text(cx), "12cde6"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(2)..MultiBufferOffset(2)] ); // First two transactions happened within the group interval in this editor. // Undo them together and restore selections. editor.undo(&Undo, window, cx); editor.undo(&Undo, window, cx); // Undo stack is empty here, so this is a no-op. assert_eq!(editor.text(cx), "123456"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(0)..MultiBufferOffset(0)] ); // Redo the first two transactions together. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "12cde6"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(5)..MultiBufferOffset(5)] ); // Redo the last transaction on its own. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "ab2cde6"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), vec![MultiBufferOffset(6)..MultiBufferOffset(6)] ); // Test empty transactions. editor.start_transaction_at(now, window, cx); editor.end_transaction_at(now, cx); editor.undo(&Undo, window, cx); assert_eq!(editor.text(cx), "12cde6"); }); } #[gpui::test] fn test_undo_redo_with_empty_history_selections_does_not_panic(cx: &mut TestAppContext) { init_test(cx, |_| {}); let now = Instant::now(); let buffer = cx.new(|cx| language::Buffer::local("123456", cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let editor = cx.add_window(|window, cx| build_editor(buffer, window, cx)); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); editor.start_transaction_at(now, window, cx); editor.insert("a", window, cx); let transaction_id = editor .end_transaction_at(now, cx) .expect("transaction should be created"); assert_eq!(editor.text(cx), "a123456"); editor.modify_transaction_selection_history(transaction_id, |selections| { selections.undo = Vec::new().into(); selections.redo = Some(Vec::new().into()); }); editor.undo(&Undo, window, cx); assert_eq!(editor.text(cx), "123456"); editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "a123456"); }); } #[gpui::test] fn test_accessibility_keyboard_word_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Simulates the macOS Accessibility Keyboard word completion panel, which calls // insertText:replacementRange: to commit a completion. macOS sends two calls per // completion: one with a non-empty range replacing the typed prefix, and one with // an empty replacement range (cursor..cursor) to append a trailing space. cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("ab", cx); let mut editor = build_editor(buffer, window, cx); // Cursor is after the 2-char prefix "ab" at offset 2. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) }); // macOS completes "about" by replacing the prefix via range 0..2. editor.replace_text_in_range(Some(0..2), "about", window, cx); assert_eq!(editor.text(cx), "about"); // macOS sends a trailing space as an empty replacement range (cursor..cursor). // Must insert at the cursor position, not call backspace first (which would // delete the preceding character). editor.replace_text_in_range(Some(5..5), " ", window, cx); assert_eq!(editor.text(cx), "about "); editor }); // Multi-cursor: the replacement must fan out to all cursors, and the trailing // space must land at each cursor's actual current position. After the first // completion, macOS's reported cursor offset is stale (it doesn't account for // the offset shift caused by the other cursor's insertion), so the empty // replacement range must be ignored and the space inserted at each real cursor. cx.add_window(|window, cx| { // Two cursors, each after a 2-char prefix "ab" at the end of each line: // "ab\nab" — cursors at offsets 2 and 5. let buffer = MultiBuffer::build_simple("ab\nab", cx); let mut editor = build_editor(buffer, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffset(2)..MultiBufferOffset(2), MultiBufferOffset(5)..MultiBufferOffset(5), ]) }); // macOS reports the newest cursor (offset 5) and sends range 3..5 to // replace its 2-char prefix. selection_replacement_ranges applies the same // delta to fan out to both cursors: 0..2 and 3..5. editor.replace_text_in_range(Some(3..5), "about", window, cx); assert_eq!(editor.text(cx), "about\nabout"); // Trailing space via empty range. macOS thinks the cursor is at offset 10 // (5 - 2 + 7 = 10), but the actual cursors are at 5 and 11. The stale // offset must be ignored and the space inserted at each real cursor position. editor.replace_text_in_range(Some(10..10), " ", window, cx); assert_eq!(editor.text(cx), "about \nabout "); editor }); } #[gpui::test] fn test_ime_composition(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer = cx.new(|cx| { let mut buffer = language::Buffer::local("abcde", cx); // Ensure automatic grouping doesn't occur. buffer.set_group_interval(Duration::ZERO); buffer }); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); cx.add_window(|window, cx| { let mut editor = build_editor(buffer.clone(), window, cx); // Start a new IME composition. editor.replace_and_mark_text_in_range(Some(0..1), "à", None, window, cx); editor.replace_and_mark_text_in_range(Some(0..1), "á", None, window, cx); editor.replace_and_mark_text_in_range(Some(0..1), "ä", None, window, cx); assert_eq!(editor.text(cx), "äbcde"); assert_eq!( editor.marked_text_ranges(cx), Some(vec![ MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) ]) ); // Finalize IME composition. editor.replace_text_in_range(None, "ā", window, cx); assert_eq!(editor.text(cx), "ābcde"); assert_eq!(editor.marked_text_ranges(cx), None); // IME composition edits are grouped and are undone/redone at once. editor.undo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "abcde"); assert_eq!(editor.marked_text_ranges(cx), None); editor.redo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "ābcde"); assert_eq!(editor.marked_text_ranges(cx), None); // Start a new IME composition. editor.replace_and_mark_text_in_range(Some(0..1), "à", None, window, cx); assert_eq!( editor.marked_text_ranges(cx), Some(vec![ MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) ]) ); // Undoing during an IME composition cancels it. editor.undo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "ābcde"); assert_eq!(editor.marked_text_ranges(cx), None); // Start a new IME composition with an invalid marked range, ensuring it gets clipped. editor.replace_and_mark_text_in_range(Some(4..999), "è", None, window, cx); assert_eq!(editor.text(cx), "ābcdè"); assert_eq!( editor.marked_text_ranges(cx), Some(vec![ MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(5)) ]) ); // Finalize IME composition with an invalid replacement range, ensuring it gets clipped. editor.replace_text_in_range(Some(4..999), "ę", window, cx); assert_eq!(editor.text(cx), "ābcdę"); assert_eq!(editor.marked_text_ranges(cx), None); // Start a new IME composition with multiple cursors. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(1)), MultiBufferOffsetUtf16(OffsetUtf16(3))..MultiBufferOffsetUtf16(OffsetUtf16(3)), MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(5)), ]) }); editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, window, cx); assert_eq!(editor.text(cx), "XYZbXYZdXYZ"); assert_eq!( editor.marked_text_ranges(cx), Some(vec![ MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(3)), MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(7)), MultiBufferOffsetUtf16(OffsetUtf16(8))..MultiBufferOffsetUtf16(OffsetUtf16(11)) ]) ); // Ensure the newly-marked range gets treated as relative to the previously-marked ranges. editor.replace_and_mark_text_in_range(Some(1..2), "1", None, window, cx); assert_eq!(editor.text(cx), "X1ZbX1ZdX1Z"); assert_eq!( editor.marked_text_ranges(cx), Some(vec![ MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(2)), MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(6)), MultiBufferOffsetUtf16(OffsetUtf16(9))..MultiBufferOffsetUtf16(OffsetUtf16(10)) ]) ); // Finalize IME composition with multiple cursors. editor.replace_text_in_range(Some(9..10), "2", window, cx); assert_eq!(editor.text(cx), "X2ZbX2ZdX2Z"); assert_eq!(editor.marked_text_ranges(cx), None); editor }); } #[gpui::test] fn test_selection_with_mouse(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); _ = editor.update(cx, |editor, window, cx| { editor.update_selection( DisplayPoint::new(DisplayRow(3), 3), 0, gpui::Point::::default(), window, cx, ); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); _ = editor.update(cx, |editor, window, cx| { editor.update_selection( DisplayPoint::new(DisplayRow(1), 1), 0, gpui::Point::::default(), window, cx, ); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] ); _ = editor.update(cx, |editor, window, cx| { editor.end_selection(window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(3), 3), 0, gpui::Point::::default(), window, cx, ); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] ); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(3), 3), true, 1, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(0), 0), 0, gpui::Point::::default(), window, cx, ); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [ DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(0), 0) ] ); _ = editor.update(cx, |editor, window, cx| { editor.end_selection(window, cx); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(0), 0)] ); } #[gpui::test] fn test_multiple_cursor_removal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 1), false, 1, window, cx); }); _ = editor.update(cx, |editor, window, cx| { editor.end_selection(window, cx); }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(3), 2), true, 1, window, cx); }); _ = editor.update(cx, |editor, window, cx| { editor.end_selection(window, cx); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [ DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2) ] ); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 1), true, 1, window, cx); }); _ = editor.update(cx, |editor, window, cx| { editor.end_selection(window, cx); }); assert_eq!( editor .update(cx, |editor, _, cx| display_ranges(editor, cx)) .unwrap(), [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] ); } #[gpui::test] fn test_canceling_pending_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); }); _ = editor.update(cx, |editor, window, cx| { editor.update_selection( DisplayPoint::new(DisplayRow(3), 3), 0, gpui::Point::::default(), window, cx, ); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); }); _ = editor.update(cx, |editor, window, cx| { editor.cancel(&Cancel, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(1), 1), 0, gpui::Point::::default(), window, cx, ); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] ); }); } #[gpui::test] fn test_movement_actions_with_pending_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); editor.move_down(&Default::default(), window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] ); editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] ); editor.move_up(&Default::default(), window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2)] ); }); } #[gpui::test] fn test_extending_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 1, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5)] ); editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 10)] ); editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); editor.end_selection(window, cx); editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 2, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 11)] ); editor.update_selection( DisplayPoint::new(DisplayRow(0), 1), 0, gpui::Point::::default(), window, cx, ); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 1, window, cx); editor.end_selection(window, cx); editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 2, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] ); editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 11)] ); editor.update_selection( DisplayPoint::new(DisplayRow(0), 6), 0, gpui::Point::::default(), window, cx, ); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] ); editor.update_selection( DisplayPoint::new(DisplayRow(0), 1), 0, gpui::Point::::default(), window, cx, ); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 0)] ); }); } #[gpui::test] fn test_extend_selection_after_collapsed_word_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { // Double-click "bbb" to select it word-wise. editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] ); // Move the cursor with the keyboard, collapsing the selection. editor.move_right(&MoveRight, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)] ); // Shift+click further along the line should extend from the cursor, // not from the stale double-clicked word. editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)] ); }); } #[gpui::test] fn test_extend_selection_after_collapsed_word_selection_moved_via_offsets(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { // Double-click "bbb" to select it word-wise. editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] ); // Collapse the selection through `move_offsets_with`, the code path // used by non-keyboard-movement callers like // `move_to_enclosing_bracket` and `select_delimiters_impl`. editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.move_offsets_with(&mut |_, selection| { selection.collapse_to(MultiBufferOffset(7), SelectionGoal::None); }); }); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)] ); // Shift+click further along the line should extend from the cursor, // not from the stale double-clicked word. editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)] ); }); } #[gpui::test] fn test_extend_selection_from_empty_line_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa\nbbb\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 0), false, 3, window, cx); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0)] ); editor.extend_selection(DisplayPoint::new(DisplayRow(0), 1), 1, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); } #[gpui::test] fn test_clone(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (text, selection_ranges) = marked_text_ranges( indoc! {" one two threeˇ four fiveˇ "}, true, ); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&text, cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges( selection_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), ) }); editor.fold_creases( vec![ Crease::simple(Point::new(1, 0)..Point::new(2, 0), FoldPlaceholder::test()), Crease::simple(Point::new(3, 0)..Point::new(4, 0), FoldPlaceholder::test()), ], true, window, cx, ); }); let cloned_editor = editor .update(cx, |editor, _, cx| { cx.open_window(Default::default(), |window, cx| { cx.new(|cx| editor.clone(window, cx)) }) }) .unwrap() .unwrap(); let snapshot = editor .update(cx, |e, window, cx| e.snapshot(window, cx)) .unwrap(); let cloned_snapshot = cloned_editor .update(cx, |e, window, cx| e.snapshot(window, cx)) .unwrap(); assert_eq!( cloned_editor .update(cx, |e, _, cx| e.display_text(cx)) .unwrap(), editor.update(cx, |e, _, cx| e.display_text(cx)).unwrap() ); assert_eq!( cloned_snapshot .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) .collect::>(), snapshot .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) .collect::>(), ); assert_set_eq!( cloned_editor .update(cx, |editor, _, cx| editor .selections .ranges::(&editor.display_snapshot(cx))) .unwrap(), editor .update(cx, |editor, _, cx| editor .selections .ranges(&editor.display_snapshot(cx))) .unwrap() ); assert_set_eq!( cloned_editor .update(cx, |e, _window, cx| e .selections .display_ranges(&e.display_snapshot(cx))) .unwrap(), editor .update(cx, |e, _, cx| e .selections .display_ranges(&e.display_snapshot(cx))) .unwrap() ); } #[gpui::test] fn test_toggle_breadcrumb_does_not_change_settings(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.toolbar.get_or_insert_default().breadcrumbs = Some(true); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("hello", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); assert_eq!( editor.breadcrumb_location(cx), ToolbarItemLocation::PrimaryLeft ); editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); }); // Changing unrelated settings should not affect breadcrumbs visibility. update_test_editor_settings(cx, &|settings| { settings.vertical_scroll_margin = Some(4.0); }); cx.run_until_parked(); _ = editor.update(cx, |editor, window, cx| { assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); assert_eq!( editor.breadcrumb_location(cx), ToolbarItemLocation::PrimaryLeft ); }); } #[gpui::test] async fn test_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); use workspace::item::Item; let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); _ = window.update(cx, |_mw, window, cx| { cx.new(|cx| { let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); let mut editor = build_editor(buffer, window, cx); let handle = cx.entity(); editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); fn pop_history(editor: &mut Editor, cx: &mut App) -> Option { editor.nav_history.as_mut().unwrap().pop_backward(cx) } // Move the cursor a small distance. // Nothing is added to the navigation history. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) ]) }); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0) ]) }); assert!(pop_history(&mut editor, cx).is_none()); // Move the cursor a large distance. // The history can jump back to the previous position. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(13), 0)..DisplayPoint::new(DisplayRow(13), 3) ]) }); let nav_entry = pop_history(&mut editor, cx).unwrap(); editor.navigate(nav_entry.data.unwrap(), window, cx); assert_eq!(nav_entry.item.id(), cx.entity_id()); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); // Move the cursor a small distance via the mouse. // Nothing is added to the navigation history. editor.begin_selection(DisplayPoint::new(DisplayRow(5), 0), false, 1, window, cx); editor.end_selection(window, cx); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); // Move the cursor a large distance via the mouse. // The history can jump back to the previous position. editor.begin_selection(DisplayPoint::new(DisplayRow(15), 0), false, 1, window, cx); editor.end_selection(window, cx); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0)] ); let nav_entry = pop_history(&mut editor, cx).unwrap(); editor.navigate(nav_entry.data.unwrap(), window, cx); assert_eq!(nav_entry.item.id(), cx.entity_id()); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] ); assert!(pop_history(&mut editor, cx).is_none()); // Set scroll position to check later editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), window, cx); let original_scroll_position = editor .scroll_manager .native_anchor(&editor.display_snapshot(cx), cx); // Jump to the end of the document and adjust scroll editor.move_to_end(&MoveToEnd, window, cx); editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), window, cx); assert_ne!( editor .scroll_manager .native_anchor(&editor.display_snapshot(cx), cx), original_scroll_position ); let nav_entry = pop_history(&mut editor, cx).unwrap(); editor.navigate(nav_entry.data.unwrap(), window, cx); assert_eq!( editor .scroll_manager .native_anchor(&editor.display_snapshot(cx), cx), original_scroll_position ); let other_buffer = cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local("test", cx)), cx)); // Ensure we don't panic when navigation data contains invalid anchors *and* points. let invalid_anchor = other_buffer.update(cx, |buffer, cx| { buffer.snapshot(cx).anchor_after(MultiBufferOffset(3)) }); let invalid_point = Point::new(9999, 0); editor.navigate( Arc::new(NavigationData { cursor_anchor: invalid_anchor, cursor_position: invalid_point, scroll_anchor: ScrollAnchor { anchor: invalid_anchor, offset: Default::default(), }, scroll_top_row: invalid_point.row, }), window, cx, ); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), &[editor.max_point(cx)..editor.max_point(cx)] ); assert_eq!( editor.scroll_position(cx), gpui::Point::new(0., editor.max_point(cx).row().as_f64()) ); editor }) }); } #[gpui::test] fn test_cancel(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.begin_selection(DisplayPoint::new(DisplayRow(3), 4), false, 1, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(1), 1), 0, gpui::Point::::default(), window, cx, ); editor.end_selection(window, cx); editor.begin_selection(DisplayPoint::new(DisplayRow(0), 1), true, 1, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(0), 3), 0, gpui::Point::::default(), window, cx, ); editor.end_selection(window, cx); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.cancel(&Cancel, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1)] ); }); _ = editor.update(cx, |editor, window, cx| { editor.cancel(&Cancel, window, cx); assert_eq!( display_ranges(editor, cx), [DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1)] ); }); } #[gpui::test] async fn test_fold_action(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.set_state(indoc! {" impl Foo { // Hello! fn a() { 1 } fn b() { 2 } fn c() { 3 } }ˇ "}); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0) ]); }); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " impl Foo { // Hello! fn a() { 1 } fn b() {⋯} fn c() {⋯} } " .unindent(), ); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " impl Foo {⋯} " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), " impl Foo { // Hello! fn a() { 1 } fn b() {⋯} fn c() {⋯} } " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); }); } #[gpui::test] fn test_fold_action_without_language(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple( &" impl Foo { // Hello! fn a() { 1 } fn b() { 2 } fn c() { 3 } } " .unindent(), cx, ); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0) ]); }); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " impl Foo { // Hello! fn a() { 1 } fn b() {⋯ } fn c() {⋯ } } " .unindent(), ); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " impl Foo {⋯ } " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), " impl Foo { // Hello! fn a() { 1 } fn b() {⋯ } fn c() {⋯ } } " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); }); } #[gpui::test] fn test_fold_action_whitespace_sensitive_language(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple( &" class Foo: # Hello! def a(): print(1) def b(): print(2) def c(): print(3) " .unindent(), cx, ); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(10), 0) ]); }); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a(): print(1) def b():⋯ def c():⋯ " .unindent(), ); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " class Foo:⋯ " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a(): print(1) def b():⋯ def c():⋯ " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); }); } #[gpui::test] fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple( &" class Foo: # Hello! def a(): print(1) def b(): print(2) def c(): print(3) " .unindent(), cx, ); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(11), 0) ]); }); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a(): print(1) def b():⋯ def c():⋯ " .unindent(), ); editor.fold(&Fold, window, cx); assert_eq!( editor.display_text(cx), " class Foo:⋯ " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a(): print(1) def b():⋯ def c():⋯ " .unindent(), ); editor.unfold_lines(&UnfoldLines, window, cx); assert_eq!( editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); }); } #[gpui::test] async fn test_fold_with_unindented_multiline_raw_string(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_queries(LanguageQueries { overrides: Some(Cow::from(indoc! {" [ (string_literal) (raw_string_literal) ] @string [ (line_comment) (block_comment) ] @comment.inclusive "})), ..Default::default() }) .expect("Could not parse queries"), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" fn main() { let s = r#\" a b c \"#; }ˇ "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" fn main() {⋯ } "}, ); }); } #[gpui::test] async fn test_fold_with_unindented_multiline_raw_string_includes_closing_bracket( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.set_state(indoc! {" ˇfn main() { let s = r#\" a b c \"#; } "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" fn main() {⋯} "}, ); }); } #[gpui::test] async fn test_fold_with_unindented_multiline_block_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_queries(LanguageQueries { overrides: Some(Cow::from(indoc! {" [ (string_literal) (raw_string_literal) ] @string [ (line_comment) (block_comment) ] @comment.inclusive "})), ..Default::default() }) .expect("Could not parse queries"), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" fn main() { let x = 1; /* unindented comment line */ }ˇ "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" fn main() {⋯ } "}, ); }); } #[gpui::test] async fn test_fold_with_unindented_multiline_block_comment_includes_closing_bracket( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.set_state(indoc! {" ˇfn main() { let x = 1; /* unindented comment line */ } "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" fn main() {⋯} "}, ); }); } #[gpui::test] async fn test_fold_preserves_top_level_comments_between_python_classes(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_python::LANGUAGE.into()), ) .with_queries(LanguageQueries { overrides: Some(Cow::from(indoc! {" (comment) @comment.inclusive (string) @string "})), ..Default::default() }) .expect("Could not parse queries"), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" class Foo: def bar(self): pass # SECTION SEPARATOR class Baz: def qux(self): passˇ "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" class Foo:⋯ # SECTION SEPARATOR class Baz: def qux(self): pass "}, ); }); } #[gpui::test] async fn test_fold_preserves_top_level_comments_between_rust_functions(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_queries(LanguageQueries { overrides: Some(Cow::from(indoc! {" [ (string_literal) (raw_string_literal) ] @string [ (line_comment) (block_comment) ] @comment.inclusive "})), ..Default::default() }) .expect("Could not parse queries"), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" fn foo() { bar(); } // SECTION SEPARATOR fn baz() { qux();ˇ } "}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {" fn foo() {⋯ } // SECTION SEPARATOR fn baz() { qux(); } "}, ); }); } #[gpui::test] async fn test_fold_terminates_at_top_level_multiline_string_between_python_classes( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_python::LANGUAGE.into()), ) .with_queries(LanguageQueries { overrides: Some(Cow::from(indoc! {" (comment) @comment.inclusive (string) @string "})), ..Default::default() }) .expect("Could not parse queries"), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {r#" class Foo: def bar(self): pass """ top-level docstring at zero indent """ class Baz: def qux(self): passˇ "#}); cx.update_editor(|editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), indoc! {r#" class Foo:⋯ """ top-level docstring at zero indent """ class Baz: def qux(self): pass "#}, ); }); } #[gpui::test] fn test_fold_at_level(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple( &" class Foo: # Hello! def a(): print(1) def b(): print(2) class Bar: # World! def a(): print(1) def b(): print(2) " .unindent(), cx, ); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.fold_at_level(&FoldAtLevel(2), window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a():⋯ def b():⋯ class Bar: # World! def a():⋯ def b():⋯ " .unindent(), ); editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), " class Foo:⋯ class Bar:⋯ " .unindent(), ); editor.unfold_all(&UnfoldAll, window, cx); editor.fold_at_level(&FoldAtLevel(0), window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a(): print(1) def b(): print(2) class Bar: # World! def a(): print(1) def b(): print(2) " .unindent(), ); assert_eq!( editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); let (_, positions) = marked_text_ranges( &" class Foo: # Hello! def a(): print(1) def b(): p«riˇ»nt(2) class Bar: # World! def a(): «ˇprint(1) def b(): print(2)» " .unindent(), true, ); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges( positions .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), ) }); editor.fold_at_level(&FoldAtLevel(2), window, cx); assert_eq!( editor.display_text(cx), " class Foo: # Hello! def a():⋯ def b(): print(2) class Bar: # World! def a(): print(1) def b(): print(2) " .unindent(), ); }); } #[gpui::test] fn test_fold_at_level_chain_fold(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let text = " fn foo( a: i32, ) { if true { let b = a; } else { let b = a; } } " .unindent(); let buffer = cx.new(|cx| Buffer::local(&text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); build_editor(buffer, window, cx) }); editor .update(cx, |editor, window, cx| { editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), " fn foo(⋯) {⋯} " .unindent(), ); editor.unfold_all(&UnfoldAll, window, cx); editor.fold_at_level(&FoldAtLevel(2), window, cx); assert_eq!( editor.display_text(cx), " fn foo( a: i32, ) { if true {⋯} else {⋯} } " .unindent(), ); }) .unwrap(); } #[gpui::test] fn test_fold_at_level_with_single_row_crease(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\n@file.txt and more\ncccccc\n", cx); build_editor(buffer, window, cx) }); editor .update(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 9)); editor.insert_creases(Some(Crease::simple(range, FoldPlaceholder::test())), cx); editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!( editor.display_text(cx), " aaaaaa ⋯ and more cccccc " .unindent(), ); }) .unwrap(); } #[gpui::test] fn test_fold_at_level_with_crease_on_boundary_row(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd eee\nfff\n", cx); build_editor(buffer, window, cx) }); editor .update(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let outer = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(3, 7)); let inner = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_after(Point::new(3, 3)); editor.insert_creases( [ Crease::simple(outer, FoldPlaceholder::test()), Crease::simple(inner, FoldPlaceholder::test()), ], cx, ); editor.fold_at_level(&FoldAtLevel(1), window, cx); assert_eq!(editor.display_text(cx), "aaa\n⋯\nfff\n"); editor.unfold_all(&UnfoldAll, window, cx); editor.fold_at_level(&FoldAtLevel(2), window, cx); assert_eq!(editor.display_text(cx), "aaa\nbbb\nccc\nddd eee\nfff\n"); }) .unwrap(); } #[gpui::test] fn test_move_cursor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer = cx.update(|cx| MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx)); let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); buffer.update(cx, |buffer, cx| { buffer.edit( vec![ (Point::new(1, 0)..Point::new(1, 0), "\t"), (Point::new(1, 1)..Point::new(1, 1), "\t"), ], None, cx, ); }); _ = editor.update(cx, |editor, window, cx| { assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.move_down(&MoveDown, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] ); editor.move_right(&MoveRight, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4)] ); editor.move_left(&MoveLeft, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] ); editor.move_up(&MoveUp, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.move_to_end(&MoveToEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 6)] ); editor.move_to_beginning(&MoveToBeginning, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 2) ]); }); editor.select_to_beginning(&SelectToBeginning, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.select_to_end(&SelectToEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(5), 6)] ); }); } #[gpui::test] fn test_move_cursor_multibyte(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("🟥🟧🟨🟩🟦🟪\nabcde\nαβγδε", cx); build_editor(buffer, window, cx) }); assert_eq!('🟥'.len_utf8(), 4); assert_eq!('α'.len_utf8(), 2); _ = editor.update(cx, |editor, window, cx| { editor.fold_creases( vec![ Crease::simple(Point::new(0, 8)..Point::new(0, 16), FoldPlaceholder::test()), Crease::simple(Point::new(1, 2)..Point::new(1, 4), FoldPlaceholder::test()), Crease::simple(Point::new(2, 4)..Point::new(2, 8), FoldPlaceholder::test()), ], true, window, cx, ); assert_eq!(editor.display_text(cx), "🟥🟧⋯🟦🟪\nab⋯e\nαβ⋯ε"); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧⋯".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_left(&MoveLeft, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯".len())]); editor.move_left(&MoveLeft, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab".len())]); editor.move_left(&MoveLeft, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "a".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "α".len())]); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ".len())]); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯".len())]); editor.move_right(&MoveRight, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); editor.move_left(&MoveLeft, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); editor.move_left(&MoveLeft, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); }); } #[gpui::test] fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); }); // moving above start of document should move selection to start of document, // but the next move down should still be at the original goal_x editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(1, "abcd".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); // moving past end of document should not change goal_x editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); editor.move_down(&MoveDown, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); editor.move_up(&MoveUp, window, cx); assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); }); } #[gpui::test] fn test_beginning_end_of_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let move_to_beg = MoveToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }; let delete_to_beg = DeleteToBeginningOfLine { stop_at_indent: false, }; let move_to_end = MoveToEndOfLine { stop_at_soft_wraps: true, }; let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\n def", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), ]); }); }); _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), ] ); }); // Moving to the end of line again is a no-op. _ = editor.update(cx, |editor, window, cx| { editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_left(&MoveLeft, window, cx); editor.select_to_beginning_of_line( &SelectToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.select_to_beginning_of_line( &SelectToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.select_to_beginning_of_line( &SelectToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.select_to_end_of_line( &SelectToEndOfLine { stop_at_soft_wraps: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 5), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); assert_eq!(editor.display_text(cx), "ab\n de"); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.delete_to_beginning_of_line(&delete_to_beg, window, cx); assert_eq!(editor.display_text(cx), "\n"); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), ] ); }); } #[gpui::test] fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor, window, cx| { editor.set_text(" indented text", window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) ]); }); editor.move_to_beginning_of_line( &MoveToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) ]); }); editor.select_to_beginning_of_line( &SelectToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 0)] ); }); } #[gpui::test] fn test_only_focused_editor_blinks_across_window_activation(cx: &mut TestAppContext) { init_test(cx, |_| {}); let window = cx.add_window(|window, cx| Editor::single_line(window, cx)); let unfocused_editor = window .update(cx, |focused_editor, window, cx| { window.focus(&focused_editor.focus_handle(cx), cx); let unfocused_editor = cx.new(|cx| Editor::single_line(window, cx)); window.activate_window(); unfocused_editor }) .unwrap(); let focused_editor = window.root(cx).unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); cx.run_until_parked(); cx.update(|window, cx| { assert!(window.is_window_active()); assert!(focused_editor.read(cx).blink_manager.read(cx).enabled()); assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); }); cx.deactivate_window(); cx.update(|window, cx| { assert!(!window.is_window_active()); assert!(!focused_editor.read(cx).blink_manager.read(cx).enabled()); assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); }); cx.update(|window, _| window.activate_window()); cx.run_until_parked(); cx.update(|window, cx| { assert!(window.is_window_active()); assert!(focused_editor.read(cx).blink_manager.read(cx).enabled()); assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); }); } #[gpui::test] fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let move_to_beg = MoveToBeginningOfLine { stop_at_soft_wraps: false, stop_at_indent: false, }; let move_to_end = MoveToEndOfLine { stop_at_soft_wraps: false, }; let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("thequickbrownfox\njumpedoverthelazydogs", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.set_wrap_width(Some(140.0.into()), cx); // We expect the following lines after wrapping // ``` // thequickbrownfox // jumpedoverthelazydo // gs // ``` // The final `gs` was soft-wrapped onto a new line. assert_eq!( "thequickbrownfox\njumpedoverthelaz\nydogs", editor.display_text(cx), ); // First, let's assert behavior on the first line, that was not soft-wrapped. // Start the cursor at the `k` on the first line editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7) ]); }); // Moving to the beginning of the line should put us at the beginning of the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),], display_ranges(editor, cx) ); // Moving to the end of the line should put us at the end of the line. editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(0), 16)..DisplayPoint::new(DisplayRow(0), 16),], display_ranges(editor, cx) ); // Now, let's assert behavior on the second line, that ended up being soft-wrapped. // Start the cursor at the last line (`y` that was wrapped to a new line) editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0) ]); }); // Moving to the beginning of the line should put us at the start of the second line of // display text, i.e., the `j`. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], display_ranges(editor, cx) ); // Moving to the beginning of the line again should be a no-op. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], display_ranges(editor, cx) ); // Moving to the end of the line should put us right after the `s` that was soft-wrapped to the // next display line. editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], display_ranges(editor, cx) ); // Moving to the end of the line again should be a no-op. editor.move_to_end_of_line(&move_to_end, window, cx); assert_eq!( vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], display_ranges(editor, cx) ); }); } #[gpui::test] fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { init_test(cx, |_| {}); let move_to_beg = MoveToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }; let select_to_beg = SelectToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }; let delete_to_beg = DeleteToBeginningOfLine { stop_at_indent: true, }; let move_to_end = MoveToEndOfLine { stop_at_soft_wraps: false, }; let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\n def", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), ]); }); // Moving to the beginning of the line should put the first cursor at the beginning of the line, // and the second cursor at the first non-whitespace character in the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), ] ); // Moving to the beginning of the line again should be a no-op for the first cursor, // and should move the second cursor to the beginning of the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), ] ); // Moving to the beginning of the line again should still be a no-op for the first cursor, // and should move the second cursor back to the first non-whitespace character in the line. editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), ] ); // Selecting to the beginning of the line should select to the beginning of the line for the first cursor, // and to the first non-whitespace character in the line for the second cursor. editor.move_to_end_of_line(&move_to_end, window, cx); editor.move_left(&MoveLeft, window, cx); editor.select_to_beginning_of_line(&select_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), ] ); // Selecting to the beginning of the line again should be a no-op for the first cursor, // and should select to the beginning of the line for the second cursor. editor.select_to_beginning_of_line(&select_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), ] ); // Deleting to the beginning of the line should delete to the beginning of the line for the first cursor, // and should delete to the first non-whitespace character in the line for the second cursor. editor.move_to_end_of_line(&move_to_end, window, cx); editor.move_left(&MoveLeft, window, cx); editor.delete_to_beginning_of_line(&delete_to_beg, window, cx); assert_eq!(editor.text(cx), "c\n f"); }); } #[gpui::test] fn test_beginning_of_line_with_cursor_between_line_start_and_indent(cx: &mut TestAppContext) { init_test(cx, |_| {}); let move_to_beg = MoveToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: true, }; let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(" hello\nworld", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { // test cursor between line_start and indent_start editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3) ]); }); // cursor should move to line_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); // cursor should move to indent_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 4)] ); // cursor should move to back to line_start editor.move_to_beginning_of_line(&move_to_beg, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); } #[gpui::test] fn test_prev_next_word_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n {baz.qux()}", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11), DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4), ]) }); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", editor, cx); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", editor, cx); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx); editor.move_right(&MoveRight, window, cx); editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx); assert_selection_ranges( "use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", editor, cx, ); editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx); assert_selection_ranges( "use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}", editor, cx, ); editor.select_to_next_word_end(&SelectToNextWordEnd, window, cx); assert_selection_ranges( "use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", editor, cx, ); }); } #[gpui::test] fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.set_wrap_width(Some(140.0.into()), cx); assert_eq!( editor.display_text(cx), "use one::{\n two::three::\n four::five\n};" ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 7)..DisplayPoint::new(DisplayRow(1), 7) ]); }); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] ); editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 8)..DisplayPoint::new(DisplayRow(2), 8)] ); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] ); editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] ); }); } #[gpui::test] async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); cx.simulate_window_resize(cx.window, size(px(100.), 4. * line_height)); // The third line only contains a single space so we can later assert that the // editor's paragraph movement considers a non-blank line as a paragraph // boundary. cx.set_state(&"ˇone\ntwo\n \nthree\nfourˇ\nfive\n\nsix"); cx.update_editor(|editor, window, cx| { editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) }); cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\nˇ\nsix"); cx.update_editor(|editor, window, cx| { editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) }); cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsixˇ"); cx.update_editor(|editor, window, cx| { editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) }); cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\n\nsixˇ"); cx.update_editor(|editor, window, cx| { editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) }); cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsix"); cx.update_editor(|editor, window, cx| { editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) }); cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\n\nsix"); cx.update_editor(|editor, window, cx| { editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) }); cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix"); } #[gpui::test] async fn test_move_to_next_and_previous_comment_paragraph(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( Language::new( LanguageConfig { line_comments: vec!["// ".into()], ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // A blank comment line (`//`) splits one comment block into two paragraphs; // a code line splits blocks; and a trailing comment preceded by code // (`let x = 1; // ...`) is not a comment line at all. cx.set_state(indoc! {" ˇ// first paragraph line one // first paragraph line two // // second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); cx.run_until_parked(); let next = |cx: &mut EditorTestContext| { cx.update_editor(|editor, window, cx| { editor.move_to_next_comment_paragraph(&MoveToNextCommentParagraph, window, cx) }); }; let prev = |cx: &mut EditorTestContext| { cx.update_editor(|editor, window, cx| { editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx) }); }; // Forward: skip the second line of the first paragraph, the blank comment // line, the code line, and the trailing comment line. next(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // ˇ// second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); next(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // // second paragraph fn code() {} ˇ// third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); next(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // // second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored ˇ// fourth paragraph "}); // No paragraph after the last one: the caret stays put. next(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // // second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored ˇ// fourth paragraph "}); // Backward is the mirror image. prev(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // // second paragraph fn code() {} ˇ// third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); prev(&mut cx); cx.assert_editor_state(indoc! {" // first paragraph line one // first paragraph line two // ˇ// second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); prev(&mut cx); cx.assert_editor_state(indoc! {" ˇ// first paragraph line one // first paragraph line two // // second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); // No paragraph before the first one: the caret stays put. prev(&mut cx); cx.assert_editor_state(indoc! {" ˇ// first paragraph line one // first paragraph line two // // second paragraph fn code() {} // third paragraph let x = 1; // trailing comment, ignored // fourth paragraph "}); } #[gpui::test] async fn test_move_to_previous_comment_paragraph_skips_current_paragraph(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( Language::new( LanguageConfig { line_comments: vec!["// ".into()], ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let prev = |cx: &mut EditorTestContext| { cx.update_editor(|editor, window, cx| { editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx) }); }; // Caret in the middle of the last line of the second paragraph: moving to // the previous paragraph must skip the entire current paragraph and land on // the first paragraph's start, not on the current paragraph's own start. cx.set_state(indoc! {" // alpha one // alpha two // alpha three // beta one // beta ˇtwo "}); cx.run_until_parked(); prev(&mut cx); cx.assert_editor_state(indoc! {" ˇ// alpha one // alpha two // alpha three // beta one // beta two "}); // Same when the caret is part-way through the *first* line of the second // paragraph. cx.set_state(indoc! {" // alpha one // alpha two // alpha three // beˇta one // beta two "}); cx.run_until_parked(); prev(&mut cx); cx.assert_editor_state(indoc! {" ˇ// alpha one // alpha two // alpha three // beta one // beta two "}); // Inside the first paragraph there is no previous paragraph, so the caret // stays put rather than jumping to the current paragraph's own start. cx.set_state(indoc! {" // alpha one // alpha ˇtwo // alpha three // beta one // beta two "}); cx.run_until_parked(); prev(&mut cx); cx.assert_editor_state(indoc! {" // alpha one // alpha ˇtwo // alpha three // beta one // beta two "}); } #[gpui::test] async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(1000.), 4. * line_height + px(0.5))); cx.set_state( r#"ˇone two three four five six seven eight nine ten "#, ); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.) ); editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) ); editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 6.) ); editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) ); editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 1.) ); editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) ); }); } #[gpui::test] async fn test_newest_selection_on_screen_with_multibyte_chars(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let multibyte_line = "ã".repeat(40); cx.set_state(&format!("{multibyte_line}ˇx\n")); cx.update_editor(|editor, window, cx| { editor.set_visible_line_count(50., window, cx); editor.set_visible_column_count(60.); assert_eq!( editor.newest_selection_on_screen(window, cx), Ordering::Equal ); editor.set_visible_column_count(20.); assert_eq!( editor.newest_selection_on_screen(window, cx), Ordering::Greater ); }); let ascii_line = "a".repeat(40); cx.set_state(&format!("{ascii_line}ˇx\n")); cx.update_editor(|editor, window, cx| { editor.set_visible_line_count(50., window, cx); editor.set_visible_column_count(60.); assert_eq!( editor.newest_selection_on_screen(window, cx), Ordering::Equal ); editor.set_visible_column_count(20.); assert_eq!( editor.newest_selection_on_screen(window, cx), Ordering::Greater ); }); } #[gpui::test] async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row) cx.simulate_window_resize(window, size(px(1000.), 5. * line_height)); // Cursor at row 0 — autoscroll leaves viewport at y=0. cx.set_state(indoc! {" ˇone two three four five six seven eight nine ten "}); cx.update_editor(|editor, window, cx| { assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3. // Cursor at row 0 < min_row=3 → clamped to row 3. editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx); assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.); let snapshot = editor.display_snapshot(cx); assert_eq!( editor.selections.newest_display(&snapshot).head().row().0, 3, "cursor clamped to min_row after scrolling down" ); }); cx.update_editor(|editor, window, cx| { // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. // Cursor at row 3 > max_row=2 → clamped to row 2. editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx); assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); let snapshot = editor.display_snapshot(cx); assert_eq!( editor.selections.newest_display(&snapshot).head().row().0, 2, "cursor clamped to max_row after scrolling up" ); }); cx.update_editor(|editor, window, cx| { // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4. // Cursor at row 2 < min_row=4 → clamped to row 4. editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx); assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.); let snapshot = editor.display_snapshot(cx); assert_eq!( editor.selections.newest_display(&snapshot).head().row().0, 4, "cursor clamped to min_row after scrolling half page down" ); }); cx.update_editor(|editor, window, cx| { // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. // Cursor at row 4 > max_row=2 → clamped to row 2. editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx); assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); let snapshot = editor.display_snapshot(cx); assert_eq!( editor.selections.newest_display(&snapshot).head().row().0, 2, "cursor clamped to max_row after scrolling half page up" ); }); } #[gpui::test] async fn test_autoscroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); cx.set_state( r#"ˇone two three four five six seven eight nine ten "#, ); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.0) ); }); // Add a cursor below the visible area. Since both cursors cannot fit // on screen, the editor autoscrolls to reveal the newest cursor, and // allows the vertical scroll margin below that cursor. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([ Point::new(0, 0)..Point::new(0, 0), Point::new(6, 0)..Point::new(6, 0), ]); }) }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.0) ); }); // Move down. The editor cursor scrolls down to track the newest cursor. cx.update_editor(|editor, window, cx| { editor.move_down(&Default::default(), window, cx); }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 4.0) ); }); // Add a cursor above the visible area. Since both cursors fit on screen, // the editor scrolls to show both. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([ Point::new(1, 0)..Point::new(1, 0), Point::new(6, 0)..Point::new(6, 0), ]); }) }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 1.0) ); }); } #[track_caller] fn assert_cursor_row_and_scroll_top( cx: &mut EditorTestContext, expected_cursor_row: u32, expected_scroll_top: f64, ) { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let cursor_row = editor .selections .newest_display(&snapshot.display_snapshot) .head() .row(); assert_eq!(cursor_row, DisplayRow(expected_cursor_row)); assert_eq!(snapshot.scroll_position().y, expected_scroll_top); }); } #[gpui::test] async fn test_autoscroll_high_margin_keeps_cursor_centered(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { // Let viewport space, rather than the configured margin, limit the padding. editor.set_vertical_scroll_margin(999, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; for autoscroll in [Autoscroll::fit(), Autoscroll::newest()] { // After allowing for the cursor row, the free space is odd, fractional, or even. for (visible_lines, expected_top_margin) in [(6., 2.), (6.5, 2.), (7., 3.)] { cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); // Start at zero-based row 6, away from file boundaries that could clamp scrolling. cx.set_state("0\n1\n2\n3\n4\n5\nˇ6\n7\n8\n9\n10\n11\n12\n13\n14"); cx.update_editor(|editor, window, cx| { editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx); editor.request_autoscroll(autoscroll, cx); }); cx.run_until_parked(); // The cursor's viewport offset is its row minus the scroll top. assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); // Moving down to row 7 must scroll with the cursor, preserving that offset. cx.update_editor(|editor, window, cx| { editor.move_down(&MoveDown, window, cx); editor.request_autoscroll(autoscroll, cx); }); cx.run_until_parked(); assert_cursor_row_and_scroll_top(&mut cx, 7, 7. - expected_top_margin); // Returning to row 6 must restore the viewport, not leave room for cursor drift. cx.update_editor(|editor, window, cx| { editor.move_up(&MoveUp, window, cx); editor.request_autoscroll(autoscroll, cx); }); cx.run_until_parked(); assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); } } } #[gpui::test] async fn test_autoscroll_auto_height_has_no_margin(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_mode(EditorMode::AutoHeight { min_lines: 1, max_lines: Some(4), }); // Auto-height inputs must ignore even a nonzero configured margin. editor.set_vertical_scroll_margin(3, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(1000.), 4. * line_height)); cx.set_state("ˇ0\n1\n2\n3\n4\n5\n6\n7\n8\n9"); cx.update_editor(|editor, _, _| { assert_eq!(editor.visible_line_count(), Some(4.)); }); // Row 2 is already visible, so moving there must not reserve space below it. cx.update_editor(|editor, window, cx| { editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); }); cx.run_until_parked(); assert_cursor_row_and_scroll_top(&mut cx, 2, 0.); // Row 4 is just outside the viewport: scroll only far enough to reveal it. cx.update_editor(|editor, window, cx| { editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); }); cx.run_until_parked(); assert_cursor_row_and_scroll_top(&mut cx, 4, 1.); } #[gpui::test] async fn test_cursor_animation_remains_active_during_keyboard_autoscroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.cursor_animation.get_or_insert_default().enabled = Some(true); }); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(3, cx); editor.set_cursor_shape(CursorShape::Block, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(1000.0), 8.0 * line_height)); let text = (0..40) .map(|row| format!("line {row}")) .collect::>() .join("\n"); cx.set_state(&format!("ˇ{text}")); cx.update(|window, cx| { window.refresh(); let _ = window.draw(cx); }); let mut previous_scroll_y = 0.0; let mut observed_autoscroll = false; for _ in 0..20 { cx.update_editor(|editor, window, cx| { editor.move_down(&Default::default(), window, cx); }); cx.update(|window, cx| { window.refresh(); let _ = window.draw(cx); }); let (scroll_y, animation_active) = cx.update_editor(|editor, window, cx| { ( editor.snapshot(window, cx).scroll_position().y, editor.cursor_animations.has_active_animation(), ) }); if scroll_y > previous_scroll_y { assert!( animation_active, "cursor animation should remain active when keyboard movement autoscrolls" ); observed_autoscroll = true; break; } previous_scroll_y = scroll_y; } assert!( observed_autoscroll, "expected cursor movement to autoscroll" ); } #[gpui::test] async fn test_autoscroll_relative(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(0, cx); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; // Resize the window such that only 6 lines of text fit on screen. cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); cx.set_state( r#"ˇone two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen "#, ); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.0) ); }); // Placing the cursor at row 7 with a top-relative autoscroll of 2 display // rows, should land the scroll position's y coordinate at 5.0 (7 - 2). cx.update_editor(|editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::top_relative(2.0)), window, cx, |selections| selections.select_ranges([Point::new(7, 0)..Point::new(7, 0)]), ); }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 5.0) ); }); // Seeing as fractional offsets are supported, with the cursor at row 10 and // a top-relative autoscroll of 2.5 display rows, the scroll position's y // coordinate lands at 7.5 (10 - 2.5). cx.update_editor(|editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::top_relative(2.5)), window, cx, |selections| selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)]), ); }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 7.5) ); }); // When the requested offset would scroll past the top of the buffer, // `scroll_position.y` is clamped to 0 rather than going negative. cx.update_editor(|editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::top_relative(4.0)), window, cx, |selections| selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]), ); }); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.0) ); }); } #[gpui::test] async fn test_autoscroll_horizontally_long_selection_tracks_cursor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let window = cx.window; // Narrow viewport so a long line can't possibly fit on screen at once. cx.simulate_window_resize(window, size(px(200.), px(300.))); let long_line = "x".repeat(250); cx.set_state(&format!("ˇ{long_line}")); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.0) ); }); // Simulate shift+end: extend the selection from column 0 to the end // of the long line. Before the fix, this selection's span was wider // than the viewport, which caused autoscroll_horizontally to bail // out entirely and leave scroll_position.x at 0. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(0, 250)]); }); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let scroll_x = editor.snapshot(window, cx).scroll_position().x; let visible_columns = editor.visible_column_count().unwrap(); let right_edge = scroll_x + visible_columns; assert!( scroll_x <= 250., "head must not be scrolled past, scroll_x = {scroll_x}" ); assert!( right_edge > 249., "head column must be at the right edge, visible columns end at {right_edge}" ); }); } #[gpui::test] async fn test_autoscroll_horizontally_padded_span_boundary_still_scrolls(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let window = cx.window; // Long line so scroll_width stays large and never clamps target_right, // isolating the case below. Viewport is tuned so a 5-char selection's // raw glyph span fits, but its padded span (+ gutter margin + em_advance, // the same padding the final scroll guard uses) does not. cx.simulate_window_resize(window, size(px(146.), px(300.))); let line = "x".repeat(300); cx.set_state(&format!("ˇ{line}")); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.0) ); }); // Select a short span in the middle of the line (e.g. simulating a // search match). Its raw glyph span fits the viewport, but the padded // span does not, so the fix must still scroll to reveal it. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 100)..Point::new(0, 105)]); }); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let scroll_x = editor.snapshot(window, cx).scroll_position().x; let visible_columns = editor.visible_column_count().unwrap(); let right_edge = scroll_x + visible_columns; assert!( scroll_x <= 105., "selection head must be visible, scroll_x = {scroll_x}" ); assert!( right_edge > 105., "selection head must be visible, visible columns end at {right_edge}" ); }); } #[gpui::test] async fn test_autoscroll_horizontally_fitting_selection_reveals_full_span(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let window = cx.window; // Viewport wide enough to fit a short selection's full span at once. cx.simulate_window_resize(window, size(px(200.), px(300.))); let line = "x".repeat(300); cx.set_state(&format!("ˇ{line}")); // Scroll right first so the whole line is out of view. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 300)..Point::new(0, 300)]); }); }); cx.run_until_parked(); let scrolled_right_x = cx.update_editor(|editor, window, cx| { let scroll_x = editor.snapshot(window, cx).scroll_position().x; assert!( scroll_x > 200., "should be scrolled far right, scroll_x = {scroll_x}" ); scroll_x }); // Select a short span in the middle of the line (e.g. simulating a // search match). Since it fits within the viewport, both edges of // the selection should end up visible, not just the head. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 100)..Point::new(0, 105)]); }); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let scroll_x = editor.snapshot(window, cx).scroll_position().x; let visible_columns = editor.visible_column_count().unwrap(); let right_edge = scroll_x + visible_columns; assert!( scroll_x < scrolled_right_x, "must scroll left towards the selection, scroll_x = {scroll_x}" ); assert!( scroll_x <= 100.5, "selection start must be visible, scroll_x = {scroll_x}" ); assert!( right_edge > 105., "selection end must be visible, visible columns end at {right_edge}" ); }); } #[gpui::test] async fn test_exclude_overscroll_margin_clamps_scroll_position(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); }); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor.set_mode(EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, }); editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); cx.set_state( &r#" ˇone two three four five six seven eight nine ten eleven "# .unindent(), ); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let max_scroll_top = (snapshot.max_point().row().as_f64() - editor.visible_line_count().unwrap() + 1.) .max(0.); editor.set_scroll_position(gpui::Point::new(0., max_scroll_top + 10.), window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., max_scroll_top) ); }); } #[gpui::test] async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; cx.simulate_window_resize(window, size(px(100.), 4. * line_height)); cx.set_state( &r#" ˇone two threeˇ four five six seven eight nine ten "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.move_page_down(&MovePageDown::default(), window, cx) }); cx.assert_editor_state( &r#" one two three ˇfour five sixˇ seven eight nine ten "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.move_page_down(&MovePageDown::default(), window, cx) }); cx.assert_editor_state( &r#" one two three four five six ˇseven eight nineˇ ten "# .unindent(), ); cx.update_editor(|editor, window, cx| editor.move_page_up(&MovePageUp::default(), window, cx)); cx.assert_editor_state( &r#" one two three ˇfour five sixˇ seven eight nine ten "# .unindent(), ); cx.update_editor(|editor, window, cx| editor.move_page_up(&MovePageUp::default(), window, cx)); cx.assert_editor_state( &r#" ˇone two threeˇ four five six seven eight nine ten "# .unindent(), ); // Test select collapsing cx.update_editor(|editor, window, cx| { editor.move_page_down(&MovePageDown::default(), window, cx); editor.move_page_down(&MovePageDown::default(), window, cx); editor.move_page_down(&MovePageDown::default(), window, cx); }); cx.assert_editor_state( &r#" one two three four five six seven eight nine ˇten ˇ"# .unindent(), ); } #[gpui::test] async fn test_delete_to_line_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for selection in ["one «two threeˇ» four", "one «ˇtwo three» four"] { cx.set_state(selection); cx.update_editor(|editor, window, cx| { editor.delete_to_beginning_of_line( &DeleteToBeginningOfLine { stop_at_indent: false, }, window, cx, ); assert_eq!(editor.text(cx), " four"); }); cx.set_state(selection); cx.update_editor(|editor, window, cx| { editor.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); assert_eq!(editor.text(cx), "one "); }); } } #[gpui::test] async fn test_delete_to_word_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // For an empty selection, the preceding word fragment is deleted. // For non-empty selections, only selected characters are deleted. cx.set_state("onˇe two t«hreˇ»e four"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: false, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇe two tˇe four"); cx.set_state("e tˇwo te «fˇ»our"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: false, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("e tˇ te ˇour"); } #[gpui::test] async fn test_delete_whitespaces(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("here is some text ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: false, ignore_brackets: true, }, window, cx, ); }); // Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action. cx.assert_editor_state("here is some textˇwith a space"); cx.set_state("here is some text ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: false, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("here is some textˇwith a space"); cx.set_state("here is some textˇ with a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: false, ignore_brackets: true, }, window, cx, ); }); // Same happens in the other direction. cx.assert_editor_state("here is some textˇwith a space"); cx.set_state("here is some textˇ with a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: false, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("here is some textˇwith a space"); cx.set_state("here is some textˇ with a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("here is some textˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("here is some ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); // Single whitespaces are removed with the word behind them. cx.assert_editor_state("here is ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("here ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇwith a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); // Same happens in the other direction. cx.assert_editor_state("ˇ a space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇ space"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇ"); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇ"); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state("ˇ"); } #[gpui::test] async fn test_delete_to_bracket(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "\"".to_string(), end: "\"".to_string(), close: true, surround: true, newline: false, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, ], ..BracketPairConfig::default() }, ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_brackets_query( r#" ("(" @open ")" @close) ("\"" @open "\"" @close) "#, ) .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(r#"macro!("// ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); // Deletion stops before brackets if asked to not ignore them. cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); // Deletion has to remove a single bracket and then stop again. cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state(r#"macro!ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state(r#"ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state(r#"ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); // Brackets on the right are not paired anymore, hence deletion does not stop at them cx.assert_editor_state(r#"ˇ");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state(r#"ˇ"#); cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }, window, cx, ); }); cx.assert_editor_state(r#"ˇ"#); cx.set_state(r#"macro!("// ˇCOMMENT");"#); cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: true, }, window, cx, ); }); cx.assert_editor_state(r#"macroˇCOMMENT");"#); } #[gpui::test] fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("one\n2\nthree\n4", cx); build_editor(buffer, window, cx) }); let del_to_prev_word_start = DeleteToPreviousWordStart { ignore_newlines: false, ignore_brackets: false, }; let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart { ignore_newlines: true, ignore_brackets: false, }; _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1) ]) }); editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree\n"); editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree"); editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\n"); editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2"); editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n"); editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); }); } #[gpui::test] fn test_delete_to_previous_subword_start_or_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("fooBar\n\nbazQux", cx); build_editor(buffer, window, cx) }); let del_to_prev_sub_word_start = DeleteToPreviousSubwordStart { ignore_newlines: false, ignore_brackets: false, }; let del_to_prev_sub_word_start_ignore_newlines = DeleteToPreviousSubwordStart { ignore_newlines: true, ignore_brackets: false, }; _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 6)..DisplayPoint::new(DisplayRow(2), 6) ]) }); editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\nbaz"); editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\n"); editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n"); editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar"); editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "foo"); editor.delete_to_previous_subword_start( &del_to_prev_sub_word_start_ignore_newlines, window, cx, ); assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); }); } #[gpui::test] fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("\none\n two\nthree\n four", cx); build_editor(buffer, window, cx) }); let del_to_next_word_end = DeleteToNextWordEnd { ignore_newlines: false, ignore_brackets: false, }; let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd { ignore_newlines: true, ignore_brackets: false, }; _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) ]) }); editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); assert_eq!( editor.buffer.read(cx).read(cx).text(), "one\n two\nthree\n four" ); editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); assert_eq!( editor.buffer.read(cx).read(cx).text(), "\n two\nthree\n four" ); editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); assert_eq!( editor.buffer.read(cx).read(cx).text(), "two\nthree\n four" ); editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "\nthree\n four"); editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four"); editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "four"); editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); }); } #[gpui::test] fn test_delete_to_next_subword_end_or_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("\nfooBar\n bazQux", cx); build_editor(buffer, window, cx) }); let del_to_next_subword_end = DeleteToNextSubwordEnd { ignore_newlines: false, ignore_brackets: false, }; let del_to_next_subword_end_ignore_newlines = DeleteToNextSubwordEnd { ignore_newlines: true, ignore_brackets: false, }; _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) ]) }); // Delete "\n" (empty line) editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n bazQux"); // Delete "foo" (subword boundary) editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "Bar\n bazQux"); // Delete "Bar" editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n bazQux"); // Delete "\n " (newline + leading whitespace) editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "bazQux"); // Delete "baz" (subword boundary) editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), "Qux"); // With ignore_newlines, delete "Qux" editor.delete_to_next_subword_end(&del_to_next_subword_end_ignore_newlines, window, cx); assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); }); } #[gpui::test] fn test_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), DisplayPoint::new(DisplayRow(1), 6)..DisplayPoint::new(DisplayRow(1), 6), ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "aa\naa\n \n bb\n bb\n"); }); } #[gpui::test] fn test_newline_trailing_whitespace(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent); }); let buffer = cx.update(|cx| MultiBuffer::build_simple(" hello\n world\n", cx)); let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), " hello\n \n world\n"); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), " hello\n\n \n world\n"); }) .unwrap(); buffer.update(cx, |buffer, cx| { let start = MultiBufferOffset(0); let end = buffer.len(cx); buffer.edit([(start..end, " hello\n world\n")], None, cx); }); editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7) ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), " hel\n lo\n world\n"); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), " hel\n\n lo\n world\n"); }) .unwrap(); update_test_language_settings(cx, &|settings| { settings.defaults.tab_size = NonZeroU32::new(4); settings.defaults.hard_tabs = Some(true); }); buffer.update(cx, |buffer, cx| { let start = MultiBufferOffset(0); let end = buffer.len(cx); buffer.edit([(start..end, "\thello\n\tworld\n")], None, cx); }); editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "\thello\n\t\n\tworld\n"); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "\thello\n\n\t\n\tworld\n"); }) .unwrap(); } #[gpui::test] async fn test_newline_yaml(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); // Object (between 2 fields) cx.set_state(indoc! {" test:ˇ hello: bye"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: ˇ hello: bye"}); // Object (first and single line) cx.set_state(indoc! {" test:ˇ"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: ˇ"}); // Array with objects (after first element) cx.set_state(indoc! {" test: - foo: barˇ"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: - foo: bar ˇ"}); // Array with objects and comment cx.set_state(indoc! {" test: - foo: bar - bar: # testˇ"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: - foo: bar - bar: # test ˇ"}); // Array with objects (after second element) cx.set_state(indoc! {" test: - foo: bar - bar: fooˇ"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: - foo: bar - bar: foo ˇ"}); // Array with strings (after first element) cx.set_state(indoc! {" test: - fooˇ"}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" test: - foo ˇ"}); } #[gpui::test] fn test_newline_with_old_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple( " a b( X ) c( X ) " .unindent() .as_str(), cx, ); let mut editor = build_editor(buffer, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(2, 4)..Point::new(2, 5), Point::new(5, 4)..Point::new(5, 5), ]) }); editor }); _ = editor.update(cx, |editor, window, cx| { // Edit the buffer directly, deleting ranges surrounding the editor's selections editor.buffer.update(cx, |buffer, cx| { buffer.edit( [ (Point::new(1, 2)..Point::new(3, 0), ""), (Point::new(4, 2)..Point::new(6, 0), ""), ], None, cx, ); assert_eq!( buffer.read(cx).text(), " a b() c() " .unindent() ); }); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 2)..Point::new(1, 2), Point::new(2, 2)..Point::new(2, 2), ], ); editor.newline(&Newline, window, cx); assert_eq!( editor.text(cx), " a b( ) c( ) " .unindent() ); // The selections are moved after the inserted newlines assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(2, 0)..Point::new(2, 0), Point::new(4, 0)..Point::new(4, 0), ], ); }); } #[gpui::test] async fn test_newline_above(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "(" ")" @end) @indent"#) .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" const a: ˇA = ( (ˇ «const_functionˇ»(ˇ), so«mˇ»et«hˇ»ing_ˇelse,ˇ )ˇ ˇ);ˇ "}); cx.update_editor(|e, window, cx| e.newline_above(&NewlineAbove, window, cx)); cx.assert_editor_state(indoc! {" ˇ const a: A = ( ˇ ( ˇ ˇ const_function(), ˇ ˇ ˇ ˇ something_else, ˇ ) ˇ ˇ ); "}); } #[gpui::test] async fn test_newline_below(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "(" ")" @end) @indent"#) .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" const a: ˇA = ( (ˇ «const_functionˇ»(ˇ), so«mˇ»et«hˇ»ing_ˇelse,ˇ )ˇ ˇ);ˇ "}); cx.update_editor(|e, window, cx| e.newline_below(&NewlineBelow, window, cx)); cx.assert_editor_state(indoc! {" const a: A = ( ˇ ( ˇ const_function(), ˇ ˇ something_else, ˇ ˇ ˇ ˇ ) ˇ ); ˇ ˇ "}); } #[gpui::test] fn test_newline_respects_read_only(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaa\nbbbb\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.set_read_only(true); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2) ]) }); editor.newline(&Newline, window, cx); assert_eq!( editor.text(cx), "aaaa\nbbbb\n", "newline should not modify a read-only editor" ); editor.newline_above(&NewlineAbove, window, cx); assert_eq!( editor.text(cx), "aaaa\nbbbb\n", "newline_above should not modify a read-only editor" ); editor.newline_below(&NewlineBelow, window, cx); assert_eq!( editor.text(cx), "aaaa\nbbbb\n", "newline_below should not modify a read-only editor" ); }); } #[gpui::test] async fn test_newline_below_with_cursor_on_deleted_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("aaa\nbbb\ncˇcc"); cx.set_head_text("aaa\nXXX\nbbb\nccc"); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(3), 3), ]); }); }); cx.update_editor(|editor, window, cx| { editor.newline_below(&NewlineBelow, window, cx); }); cx.run_until_parked(); assert_eq!(cx.buffer(|buffer, _| buffer.text()), "aaa\nbbb\nccc\n"); let cursors = cx.update_editor(|editor, window, cx| { let display_snapshot = editor.snapshot(window, cx).display_snapshot; editor .selections .all_display(&display_snapshot) .iter() .map(|selection| selection.head()) .collect::>() }); assert_eq!( cursors, vec![ DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(4), 0), ], ); } #[gpui::test] fn test_expand_excerpts_with_selection_ending_at_excerpt_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); for (direction, expected) in [ (ExpandExcerptDirection::Up, "a0\na1\na2\na3\nb1\nb2\nb3"), (ExpandExcerptDirection::Down, "a1\na2\na3\na4\nb1\nb2\nb3"), ( ExpandExcerptDirection::UpAndDown, "a0\na1\na2\na3\na4\nb1\nb2\nb3", ), ] { let mut cx = EditorTestContext::new_multibuffer( cx, ["a0\n«a1\na2\na3»\na4", "b0\n«b1\nb2\nb3»\nb4"], ); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::zero()..Point::new(3, 0)]); }); match direction { ExpandExcerptDirection::Up => { editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) } ExpandExcerptDirection::Down => { editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) } ExpandExcerptDirection::UpAndDown => { editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) } } }); assert_eq!(cx.buffer_text(), expected, "{direction:?}"); } } #[gpui::test] fn test_expand_excerpts_with_trailing_empty_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); for (direction, expected) in [ (ExpandExcerptDirection::Up, "aaa\none\n"), (ExpandExcerptDirection::Down, "aaa\n"), (ExpandExcerptDirection::UpAndDown, "aaa\none\n"), ] { for select_all in [false, true] { let mut cx = EditorTestContext::new_multibuffer(cx, ["«aaa»", "zero\none\n«»"]); cx.update_editor(|editor, window, cx| { if select_all { editor.select_all(&SelectAll, window, cx); } else { editor.move_to_end(&MoveToEnd, window, cx); } match direction { ExpandExcerptDirection::Up => { editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) } ExpandExcerptDirection::Down => { editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) } ExpandExcerptDirection::UpAndDown => { editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) } } }); assert_eq!( cx.buffer_text(), expected, "{direction:?}, select_all={select_all}" ); } } } #[gpui::test] async fn test_expand_excerpts_with_selection_on_deleted_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("zero\none\ntwo\nthree\nfour\nfive\nsix\nsevenˇ"); let buffer = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); cx.update_multibuffer(|multi_buffer, cx| { let path_key = PathKey::for_buffer(&buffer, cx); multi_buffer.set_excerpts_for_path( path_key, buffer, [Point::new(2, 0)..Point::new(5, 4)], 0, cx, ); }); cx.set_head_text("zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven"); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); assert_eq!(cx.buffer_text(), "two\nthree\ndeleted\nfour\nfive"); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { let cursor = DisplayPoint::new(DisplayRow(2), 0); selections.select_display_ranges([cursor..cursor]); }); }); assert!(cx.update_editor(|editor, _, _| { editor .selections .newest_anchor() .head() .diff_base_anchor() .is_some() })); cx.update_editor(|editor, window, cx| { editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); }); assert_eq!( cx.buffer_text(), "one\ntwo\nthree\ndeleted\nfour\nfive\nsix" ); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 4) ]); }); }); cx.update_editor(|editor, window, cx| { editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); }); assert_eq!( cx.buffer_text(), "zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven" ); } #[gpui::test] async fn test_expand_excerpts_with_selection_in_removed_path(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_a = cx.new(|cx| Buffer::local("a0\na1\na2\na3\na4", cx)); let buffer_b = cx.new(|cx| Buffer::local("b0\nb1\nb2\nb3\nb4", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_a, [Point::new(1, 0)..Point::new(3, 2)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_b.clone(), [Point::new(1, 0)..Point::new(3, 2)], 0, cx, ); multibuffer }); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [path!("/").as_ref()], cx).await; let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project, multibuffer.clone(), window, cx) }); // The second path's excerpt begins with a deleted hunk. editor.update_in(cx, |editor, window, cx| { let diff = cx.new(|cx| { BufferDiff::new_with_base_text( "b0\ndeleted\nb1\nb2\nb3\nb4", &buffer_b.read(cx).text_snapshot(), cx, ) }); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); editor.expand_all_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "a1\na2\na3\ndeleted\nb1\nb2\nb3"); }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); }); }); multibuffer.update(cx, |multibuffer, cx| { multibuffer.remove_excerpts(PathKey::sorted(0), cx); }); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "deleted\nb1\nb2\nb3"); }); editor.update_in(cx, |editor, window, cx| { editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); }); editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "b0\ndeleted\nb1\nb2\nb3\nb4"); }); } #[gpui::test] fn test_newline_below_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); assert_eq!( editor.text(cx), indoc! {" aaa bbb ccc ddd eee fff"} ); // Cursor on the last line of the first excerpt. // The newline should be inserted within the first excerpt (buffer_1), // not in the second excerpt (buffer_2). select_ranges( &mut editor, indoc! {" aaa bbb cˇcc ddd eee fff"}, window, cx, ); editor.newline_below(&NewlineBelow, window, cx); assert_text_with_selections( &mut editor, indoc! {" aaa bbb ccc ˇ ddd eee fff"}, cx, ); buffer_1.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); }); buffer_2.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "ddd\neee\nfff"); }); editor }); } #[gpui::test] fn test_newline_below_multibuffer_middle_of_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); // Cursor in the middle of the first excerpt. select_ranges( &mut editor, indoc! {" aˇaa bbb ccc ddd eee fff"}, window, cx, ); editor.newline_below(&NewlineBelow, window, cx); assert_text_with_selections( &mut editor, indoc! {" aaa ˇ bbb ccc ddd eee fff"}, cx, ); buffer_1.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "aaa\n\nbbb\nccc"); }); buffer_2.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "ddd\neee\nfff"); }); editor }); } #[gpui::test] fn test_newline_below_multibuffer_last_line_of_last_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); // Cursor on the last line of the last excerpt. select_ranges( &mut editor, indoc! {" aaa bbb ccc ddd eee fˇff"}, window, cx, ); editor.newline_below(&NewlineBelow, window, cx); assert_text_with_selections( &mut editor, indoc! {" aaa bbb ccc ddd eee fff ˇ"}, cx, ); buffer_1.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "aaa\nbbb\nccc"); }); buffer_2.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "ddd\neee\nfff\n"); }); editor }); } #[gpui::test] fn test_newline_below_multibuffer_multiple_cursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(2, 3)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); // Cursors on the last line of the first excerpt and the first line // of the second excerpt. Each newline should go into its respective buffer. select_ranges( &mut editor, indoc! {" aaa bbb cˇcc dˇdd eee fff"}, window, cx, ); editor.newline_below(&NewlineBelow, window, cx); assert_text_with_selections( &mut editor, indoc! {" aaa bbb ccc ˇ ddd ˇ eee fff"}, cx, ); buffer_1.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); }); buffer_2.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "ddd\n\neee\nfff"); }); editor }); } #[gpui::test] async fn test_newline_comments(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into()], ..LanguageConfig::default() }, None, )); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" // Fooˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" // Foo // ˇ "}); // Ensure that we add comment prefix when existing line contains space cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state( indoc! {" // Foo //s // ˇ "} .replace("s", " ") // s is used as space placeholder to prevent format on save .as_str(), ); // Ensure that we add comment prefix when existing line does not contain space cx.set_state(indoc! {" // Foo //ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" // Foo // // ˇ "}); // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. cx.set_state(indoc! {" ˇ// Foo "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" ˇ// Foo "}); } // Ensure that comment continuations can be disabled. update_test_language_settings(cx, &|settings| { settings.defaults.extend_comment_on_newline = Some(false); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" // Fooˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" // Foo ˇ "}); } #[gpui::test] async fn test_newline_comments_with_multiple_delimiters(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into(), "/// ".into()], ..LanguageConfig::default() }, None, )); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" //ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" // // ˇ "}); cx.set_state(indoc! {" ///ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /// /// ˇ "}); } } #[gpui::test] async fn test_newline_comments_with_brackets(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into()], brackets: BracketPairConfig { pairs: vec![BracketPair { start: "(".to_string(), end: ")".to_string(), close: false, surround: false, newline: true, }], ..BracketPairConfig::default() }, ..LanguageConfig::default() }, None, )); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" // (ˇ) "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" // ( // ˇ) "}) } } #[gpui::test] async fn test_newline_comments_repl_separators(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["# ".into()], ..LanguageConfig::default() }, None, )); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" # %%ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" # %% ˇ "}); cx.set_state(indoc! {" # %%%%%ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" # %%%%% ˇ "}); cx.set_state(indoc! {" # %ˇ% "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" # % # ˇ% "}); } } #[gpui::test] async fn test_newline_documentation_comments(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new( Language::new( LanguageConfig { documentation_comment: Some(language::BlockCommentConfig { start: "/**".into(), end: "*/".into(), prefix: "* ".into(), tab_size: 1, }), ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") .unwrap(), ); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" /**ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * ˇ "}); // Ensure that if cursor is before the comment start, // we do not actually insert a comment prefix. cx.set_state(indoc! {" ˇ/** "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" ˇ/** "}); // Ensure that if cursor is between it doesn't add comment prefix. cx.set_state(indoc! {" /*ˇ* "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /* ˇ* "}); // Ensure that if suffix exists on same line after cursor it adds new line. cx.set_state(indoc! {" /**ˇ*/ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * ˇ */ "}); // Ensure that if suffix exists on same line after cursor with space it adds new line. cx.set_state(indoc! {" /**ˇ */ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * ˇ */ "}); // Ensure that if suffix exists on same line after cursor with space it adds new line. cx.set_state(indoc! {" /** ˇ*/ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state( indoc! {" /**s * ˇ */ "} .replace("s", " ") // s is used as space placeholder to prevent format on save .as_str(), ); // Ensure that delimiter space is preserved when newline on already // spaced delimiter. cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state( indoc! {" /**s *s * ˇ */ "} .replace("s", " ") // s is used as space placeholder to prevent format on save .as_str(), ); // Ensure that delimiter space is preserved when space is not // on existing delimiter. cx.set_state(indoc! {" /** *ˇ */ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * * ˇ */ "}); // Ensure that if suffix exists on same line after cursor it // doesn't add extra new line if prefix is not on same line. cx.set_state(indoc! {" /** ˇ*/ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** ˇ*/ "}); // Ensure that it detects suffix after existing prefix. cx.set_state(indoc! {" /**ˇ/ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** ˇ/ "}); // Ensure that if suffix exists on same line before // cursor it does not add comment prefix. cx.set_state(indoc! {" /** */ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** */ ˇ "}); // Ensure that if suffix exists on same line before // cursor it does not add comment prefix. cx.set_state(indoc! {" /** * */ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * */ ˇ "}); cx.set_state("fn test() {\n /**\n *\n */ˇ\n}"); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state("fn test() {\n /**\n *\n */\n ˇ\n}"); // Ensure that inline comment followed by code // doesn't add comment prefix on newline cx.set_state(indoc! {" /** */ textˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** */ text ˇ "}); // Ensure that text after comment end tag // doesn't add comment prefix on newline cx.set_state(indoc! {" /** * */ˇtext "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** * */ ˇtext "}); // Ensure if not comment block it doesn't // add comment prefix on newline cx.set_state(indoc! {" * textˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" * text ˇ "}); } // Ensure that comment continuations can be disabled. update_test_language_settings(cx, &|settings| { settings.defaults.extend_comment_on_newline = Some(false); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" /**ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" /** ˇ "}); } #[gpui::test] async fn test_newline_closing_comment_indent_across_languages(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let go_language = languages::language("go", tree_sitter_go::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_language), cx)); cx.set_state("func test() {\n\t/**\n\t * doc\n\t */ˇ\n}"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}"); let typescript_language = languages::language( "typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(typescript_language), cx)); cx.set_state("function test() {\n /**\n * doc\n */ˇ\n}"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("function test() {\n /**\n * doc\n */\n ˇ\n}"); let python_language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(python_language), cx)); cx.set_state("def test():\n \"\"\"doc\n \"\"\"ˇ\n"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("def test():\n \"\"\"doc\n \"\"\"\n ˇ\n"); } #[gpui::test] async fn test_newline_after_closing_delimiter_lookalike_in_string(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); // A line inside a string literal that happens to look like a block comment's // closing delimiter does not close anything, so it keeps its own indentation. cx.set_state("func test() {\n\tvar s = `\n*/ˇ\n`\n}"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("func test() {\n\tvar s = `\n*/\nˇ\n`\n}"); } #[gpui::test] async fn test_newline_twice_inside_block(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); cx.set_state("func test() {ˇ}"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("func test() {\n ˇ\n}"); // The blank line the cursor sits on holds nothing but auto-indent // whitespace, but the line that replaces it still belongs inside the block. cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.assert_editor_state("func test() {\n\n ˇ\n}"); } #[gpui::test] async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let lua_language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["--".into()], block_comment: Some(language::BlockCommentConfig { start: "--[[".into(), prefix: "".into(), end: "]]".into(), tab_size: 0, }), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(lua_language), cx)); // Line with line comment should extend cx.set_state(indoc! {" --ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" -- --ˇ "}); // Line with block comment that matches line comment should not extend cx.set_state(indoc! {" --[[ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" --[[ ˇ "}); } #[gpui::test] fn test_insert_with_old_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); let mut editor = build_editor(buffer, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffset(3)..MultiBufferOffset(4), MultiBufferOffset(11)..MultiBufferOffset(12), MultiBufferOffset(19)..MultiBufferOffset(20), ]) }); editor }); _ = editor.update(cx, |editor, window, cx| { // Edit the buffer directly, deleting ranges surrounding the editor's selections editor.buffer.update(cx, |buffer, cx| { buffer.edit( [ (MultiBufferOffset(2)..MultiBufferOffset(5), ""), (MultiBufferOffset(10)..MultiBufferOffset(13), ""), (MultiBufferOffset(18)..MultiBufferOffset(21), ""), ], None, cx, ); assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); }); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ MultiBufferOffset(2)..MultiBufferOffset(2), MultiBufferOffset(7)..MultiBufferOffset(7), MultiBufferOffset(12)..MultiBufferOffset(12) ], ); editor.insert("Z", window, cx); assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); // The selections are moved after the inserted characters assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ MultiBufferOffset(3)..MultiBufferOffset(3), MultiBufferOffset(9)..MultiBufferOffset(9), MultiBufferOffset(15)..MultiBufferOffset(15) ], ); }); } #[gpui::test] async fn test_tab(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(3) }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇabˇc ˇ🏀ˇ🏀ˇefg dˇ "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" ˇab ˇc ˇ🏀 ˇ🏀 ˇefg d ˇ "}); cx.set_state(indoc! {" a «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" a «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» "}); } #[gpui::test] async fn test_tab_in_leading_whitespace_auto_indents_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "(" ")" @end) @indent"#) .unwrap(), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test when all cursors are not at suggested indent // then simply move to their suggested indent location cx.set_state(indoc! {" const a: B = ( c( ˇ ˇ ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( ˇ ˇ) ); "}); // test cursor already at suggested indent not moving when // other cursors are yet to reach their suggested indents cx.set_state(indoc! {" ˇ const a: B = ( c( d( ˇ ) ˇ ˇ ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" ˇ const a: B = ( c( d( ˇ ) ˇ ˇ) ); "}); // test when all cursors are at suggested indent then tab is inserted cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" ˇ const a: B = ( c( d( ˇ ) ˇ ˇ) ); "}); // test when current indent is less than suggested indent, // we adjust line to match suggested indent and move cursor to it // // when no other cursor is at word boundary, all of them should move cx.set_state(indoc! {" const a: B = ( c( d( ˇ ˇ ) ˇ ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( d( ˇ ˇ) ˇ) ); "}); // test when current indent is less than suggested indent, // we adjust line to match suggested indent and move cursor to it // // when some other cursor is at word boundary, it should not move cx.set_state(indoc! {" const a: B = ( c( d( ˇ ˇ ) ˇ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( d( ˇ ˇ) ˇ) ); "}); // test when current indent is more than suggested indent, // we just move cursor to current indent instead of suggested indent // // when no other cursor is at word boundary, all of them should move cx.set_state(indoc! {" const a: B = ( c( d( ˇ ˇ ) ˇ ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( d( ˇ ˇ) ˇ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( d( ˇ ˇ) ˇ) ); "}); // test when current indent is more than suggested indent, // we just move cursor to current indent instead of suggested indent // // when some other cursor is at word boundary, it doesn't move cx.set_state(indoc! {" const a: B = ( c( d( ˇ ˇ ) ˇ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( d( ˇ ˇ) ˇ) ); "}); // handle auto-indent when there are multiple cursors on the same line cx.set_state(indoc! {" const a: B = ( c( ˇ ˇ ˇ ) ); "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c( ˇ ˇ) ); "}); } #[gpui::test] async fn test_tab_after_closing_comment_delimiter(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); // The suggested indent is taken from the line the comment opened on, so that // it agrees with what pressing enter after the closing delimiter produces. cx.set_state("func test() {\n\t/**\n\t * doc\n\t */\nˇ\n}"); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}"); } #[gpui::test] async fn test_tab_with_mixed_whitespace_txt(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(3) }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇ \t ˇ \t ˇ \t ˇ \t \t\t \t \t\t \t\t \t \t ˇ "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" ˇ \t ˇ \t ˇ \t ˇ \t \t\t \t \t\t \t\t \t \t ˇ "}); } #[gpui::test] async fn test_tab_with_mixed_whitespace_rust(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4) }); let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "{" "}" @end) @indent"#) .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" fn a() { if b { \t ˇc } } "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" fn a() { if b { ˇc } } "}); } #[gpui::test] async fn test_indent_outdent(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" «oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" «oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" «oneˇ» «twoˇ» three four "}); // select across line ending cx.set_state(indoc! {" one two t«hree ˇ» four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" one two t«hree ˇ» four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two t«hree ˇ» four "}); // Ensure that indenting/outdenting works when the cursor is at column 0. cx.set_state(indoc! {" one two ˇthree four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" one two ˇthree four "}); cx.set_state(indoc! {" one two ˇ three four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two ˇthree four "}); } #[gpui::test] async fn test_indent_yaml_comments_with_multiple_cursors(cx: &mut TestAppContext) { // This is a regression test for issue #33761 init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); cx.set_state( r#"ˇ# ingress: ˇ# api: ˇ# enabled: false ˇ# pathType: Prefix ˇ# console: ˇ# enabled: false ˇ# pathType: Prefix "#, ); // Press tab to indent all lines cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state( r#" ˇ# ingress: ˇ# api: ˇ# enabled: false ˇ# pathType: Prefix ˇ# console: ˇ# enabled: false ˇ# pathType: Prefix "#, ); } #[gpui::test] async fn test_indent_yaml_non_comments_with_multiple_cursors(cx: &mut TestAppContext) { // This is a test to make sure our fix for issue #33761 didn't break anything init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); cx.set_state( r#"ˇingress: ˇ api: ˇ enabled: false ˇ pathType: Prefix "#, ); // Press tab to indent all lines cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state( r#"ˇingress: ˇapi: ˇenabled: false ˇpathType: Prefix "#, ); } #[gpui::test] async fn test_multicursor_input_preserves_yaml_indentation(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); let initial_state = indoc! {r#" ˇcoverage: ˇrange: 40..60 ˇstatus: ˇpatch: off ˇproject: ˇdefault: ˇinformational: true ˇ# Don't leave comments on PRs ˇcomment: false "#}; for input in ["2", "#"] { cx.set_state(initial_state); cx.update_editor(|editor, window, cx| editor.handle_input(input, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(&initial_state.replace('ˇ', &format!("{input}ˇ"))); // Recreate the cursors and delete the inserted characters, as in #21334. cx.update_editor(|editor, window, cx| editor.cancel(&Cancel, window, cx)); cx.set_selections_state(&initial_state.replace('ˇ', &format!("ˇ{input}"))); cx.update_editor(|editor, window, cx| editor.delete(&Delete, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(initial_state); } // A multiline replacement must not reindent the other single-line edits. cx.set_state(indoc! {" ˇroot: ˇchild: ˇleaf: 1 replacement: « first: 1 second: 2ˇ» "}); cx.update_editor(|editor, window, cx| editor.handle_input("2", window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 2ˇroot: 2ˇchild: 2ˇleaf: 1 replacement: 2ˇ "}); } #[gpui::test] async fn test_multicursor_input_autoindents_multiline_replacements(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let python_language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(python_language), cx)); cx.set_state(indoc! {" def f(): « a = 1 b = 2ˇ» def g(): « a = 1 b = 2ˇ» "}); cx.update_editor(|editor, window, cx| editor.handle_input("pass", window, cx)); cx.wait_for_autoindent_applied().await; assert_eq!( cx.buffer_text(), indoc! {" def f(): pass def g(): pass "} ); } #[gpui::test] async fn test_tab_indents_selected_yaml_block(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); cx.set_state(indoc! {" «foo: - bar - zop x: q bar: qˇ» "}); cx.update_editor(|editor, window, cx| editor.tab(&Tab, window, cx)); assert_eq!( cx.buffer_text(), indoc! {" \x20 foo: - bar - zop x: q bar: q "} ); } #[gpui::test] async fn test_tab_indents_overlapping_selections_consistently(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" \x20 «firstˇ»: «1 \x20 second: 2 \x20 third: 3ˇ» "}); cx.update_editor(|editor, window, cx| editor.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" \x20 «firstˇ»: «1 \x20 second: 2 \x20 third: 3ˇ» "}); } #[gpui::test] async fn test_indent_outdent_with_hard_tabs(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.hard_tabs = Some(true); }); let mut cx = EditorTestContext::new(cx).await; // select two ranges on one line cx.set_state(indoc! {" «oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" \t«oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" \t\t«oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" \t«oneˇ» «twoˇ» three four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" «oneˇ» «twoˇ» three four "}); // select across a line ending cx.set_state(indoc! {" one two t«hree ˇ»four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" one two \tt«hree ˇ»four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" one two \t\tt«hree ˇ»four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two \tt«hree ˇ»four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two t«hree ˇ»four "}); // Ensure that indenting/outdenting works when the cursor is at column 0. cx.set_state(indoc! {" one two ˇthree four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two ˇthree four "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.assert_editor_state(indoc! {" one two \tˇthree four "}); cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); cx.assert_editor_state(indoc! {" one two ˇthree four "}); } #[gpui::test] fn test_indent_outdent_with_excerpts(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.languages.0.extend([ ( "TOML".into(), LanguageSettingsContent { tab_size: NonZeroU32::new(2), ..Default::default() }, ), ( "Rust".into(), LanguageSettingsContent { tab_size: NonZeroU32::new(4), ..Default::default() }, ), ]); }); let toml_language = Arc::new(Language::new( LanguageConfig { name: "TOML".into(), ..Default::default() }, None, )); let rust_language = Arc::new(Language::new( LanguageConfig { name: "Rust".into(), ..Default::default() }, None, )); let toml_buffer = cx.new(|cx| Buffer::local("a = 1\nb = 2\n", cx).with_language(toml_language, cx)); let rust_buffer = cx.new(|cx| Buffer::local("const c: usize = 3;\n", cx).with_language(rust_language, cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), toml_buffer.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), rust_buffer.clone(), [Point::new(0, 0)..Point::new(1, 0)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); assert_eq!( editor.text(cx), indoc! {" a = 1 b = 2 const c: usize = 3; "} ); select_ranges( &mut editor, indoc! {" «aˇ» = 1 b = 2 «const c:ˇ» usize = 3; "}, window, cx, ); editor.tab(&Tab, window, cx); assert_text_with_selections( &mut editor, indoc! {" «aˇ» = 1 b = 2 «const c:ˇ» usize = 3; "}, cx, ); editor.backtab(&Backtab, window, cx); assert_text_with_selections( &mut editor, indoc! {" «aˇ» = 1 b = 2 «const c:ˇ» usize = 3; "}, cx, ); editor }); } #[gpui::test] async fn test_backspace(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Basic backspace cx.set_state(indoc! {" onˇe two three fou«rˇ» five six seven «ˇeight nine »ten "}); cx.update_editor(|e, window, cx| e.backspace(&Backspace, window, cx)); cx.assert_editor_state(indoc! {" oˇe two three fouˇ five six seven ˇten "}); // Test backspace inside and around indents cx.set_state(indoc! {" zero ˇone ˇtwo ˇ ˇ ˇ three ˇ ˇ four "}); cx.update_editor(|e, window, cx| e.backspace(&Backspace, window, cx)); cx.assert_editor_state(indoc! {" zero ˇone ˇtwo ˇ threeˇ four "}); } #[gpui::test] async fn test_delete(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" onˇe two three fou«rˇ» five six seven «ˇeight nine »ten "}); cx.update_editor(|e, window, cx| e.delete(&Delete, window, cx)); cx.assert_editor_state(indoc! {" onˇ two three fouˇ five six seven ˇten "}); } #[gpui::test] fn test_delete_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), ]) }); editor.delete_line(&DeleteLine, window, cx); assert_eq!(editor.display_text(cx), "ghi"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), ] ); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 1) ]) }); editor.delete_line(&DeleteLine, window, cx); assert_eq!(editor.display_text(cx), "ghi\n"); assert_eq!( display_ranges(editor, cx), vec![DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1)] ); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n\njkl\nmno", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(2), 1) ]) }); editor.delete_line(&DeleteLine, window, cx); assert_eq!(editor.display_text(cx), "\njkl\nmno"); assert_eq!( display_ranges(editor, cx), vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); } #[gpui::test] fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); let mut editor = build_editor(buffer.clone(), window, cx); let buffer = buffer.read(cx).as_singleton().unwrap(); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 0)..Point::new(0, 0)] ); // When on single line, replace newline at end by space editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 3)..Point::new(0, 3)] ); editor.undo(&Undo, window, cx); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n\n"); // Select a full line, i.e. start of the first line to the start of the second line editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]) }); editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); editor.undo(&Undo, window, cx); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n\n"); // Select two full lines editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(2, 0)]) }); editor.join_lines(&JoinLines, window, cx); // Only the selected lines should be joined, not the third. assert_eq!( buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n", "only the two selected lines (a and b) should be joined" ); // When multiple lines are selected, remove newlines that are spanned by the selection editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 5)..Point::new(2, 2)]) }); editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 11)..Point::new(0, 11)] ); // Undo should be transactional editor.undo(&Undo, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 5)..Point::new(2, 2)] ); // When joining an empty line don't insert a space editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(2, 1)..Point::new(2, 2)]) }); editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); // We can remove trailing newlines editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); // We don't blow up on the last line editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); // reset to test indentation editor.buffer.update(cx, |buffer, cx| { buffer.edit( [ (Point::new(1, 0)..Point::new(1, 2), " "), (Point::new(2, 0)..Point::new(2, 3), " \n\td"), ], None, cx, ) }); // We remove any leading spaces assert_eq!(buffer.read(cx).text(), "aaa bbb\n c\n \n\td"); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 1)..Point::new(0, 1)]) }); editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb c\n \n\td"); // We don't insert a space for a line containing only spaces editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb c\n\td"); // We ignore any leading tabs editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb c d"); editor }); } #[gpui::test] fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); let mut editor = build_editor(buffer.clone(), window, cx); let buffer = buffer.read(cx).as_singleton().unwrap(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 2)..Point::new(1, 1), Point::new(1, 2)..Point::new(1, 2), Point::new(3, 1)..Point::new(3, 2), ]) }); editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 7)..Point::new(0, 7), Point::new(1, 3)..Point::new(1, 3) ] ); editor }); } #[gpui::test] async fn test_join_lines_with_git_diff_base(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" Line 0 Line 1 Line 2 Line 3 "# .unindent(); cx.set_state( &r#" ˇLine 0 Line 1 Line 2 Line 3 "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); // Join lines cx.update_editor(|editor, window, cx| { editor.join_lines(&JoinLines, window, cx); }); executor.run_until_parked(); cx.assert_editor_state( &r#" Line 0ˇ Line 1 Line 2 Line 3 "# .unindent(), ); // Join again cx.update_editor(|editor, window, cx| { editor.join_lines(&JoinLines, window, cx); }); executor.run_until_parked(); cx.assert_editor_state( &r#" Line 0 Line 1ˇ Line 2 Line 3 "# .unindent(), ); } #[gpui::test] async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { init_test(cx, |_| {}); { let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); // Strips the comment prefix (with trailing space) from the joined-in line. cx.set_state(indoc! {" // ˇfoo // bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" // fooˇ bar "}); // Strips the longer doc-comment prefix when both `//` and `///` match. cx.set_state(indoc! {" /// ˇfoo /// bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" /// fooˇ bar "}); // Does not strip when the second line is a regular line (no comment prefix). cx.set_state(indoc! {" // ˇfoo bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" // fooˇ bar "}); // No-whitespace join also strips the comment prefix. cx.set_state(indoc! {" // ˇfoo // bar "}); cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx)); cx.assert_editor_state(indoc! {" // fooˇbar "}); // Strips even when the joined-in line is just the bare prefix (no trailing space). cx.set_state(indoc! {" // ˇfoo // "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" // fooˇ "}); // Mixed line comment prefix types: the longer matching prefix is stripped. cx.set_state(indoc! {" // ˇfoo /// bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" // fooˇ bar "}); // Strips block comment body prefix (`* `) from the joined-in line. cx.set_state(indoc! {" /* * ˇfoo * bar */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" /* * fooˇ bar */ "}); // Strips bare block comment body prefix (`*` without trailing space). cx.set_state(indoc! {" /* * ˇfoo * */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" /* * fooˇ */ "}); } { let markdown_language = Arc::new(Language::new( LanguageConfig { unordered_list: vec!["- ".into(), "* ".into(), "+ ".into()], ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Strips the `- ` list marker from the joined-in line. cx.set_state(indoc! {" - ˇfoo - bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" - fooˇ bar "}); // Strips the `* ` list marker from the joined-in line. cx.set_state(indoc! {" * ˇfoo * bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" * fooˇ bar "}); // Strips the `+ ` list marker from the joined-in line. cx.set_state(indoc! {" + ˇfoo + bar "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" + fooˇ bar "}); cx.set_state(indoc! {" fooˇ *bar* "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" fooˇ *bar* "}); cx.set_state(indoc! {" * ˇfoo * "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" * fooˇ "}); // No-whitespace join also strips the list marker. cx.set_state(indoc! {" - ˇfoo - bar "}); cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx)); cx.assert_editor_state(indoc! {" - fooˇbar "}); } } #[gpui::test] async fn test_join_lines_preserves_rust_operators(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); for insert_whitespace in [true, false] { let separator = if insert_whitespace { " " } else { "" }; for indent in ["", " ", "\t"] { for (first_line, next_line) in [ ("let value =", "*pointer;"), ("let value =", "* pointer;"), ("let value =", "**pointer;"), ("let value =", "*指针;"), ("let value = 2", "* 3;"), ("let value = \"text", "*text\";"), ("let value = r#\"text", "*text\"#;"), ] { cx.set_state(&formatdoc! {" fn main() {{ {first_line}ˇ {indent}{next_line} }}"}); cx.update_editor(|editor, window, cx| { editor.join_lines_impl(insert_whitespace, window, cx) }); cx.assert_editor_state(&formatdoc! {" fn main() {{ {first_line}ˇ{separator}{next_line} }}"}); } } } } #[gpui::test] async fn test_join_lines_rust_block_comments(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); for insert_whitespace in [true, false] { let separator = if insert_whitespace { " " } else { "" }; for start in ["/*", "/**", "/*!"] { for next_line in ["* bar", "*bar"] { cx.set_state(&formatdoc! {" {start} * fooˇ {next_line} */"}); cx.update_editor(|editor, window, cx| { editor.join_lines_impl(insert_whitespace, window, cx) }); cx.assert_editor_state(&formatdoc! {" {start} * fooˇ{separator}bar */"}); } cx.set_state(&formatdoc! {" {start} * fooˇ * */"}); cx.update_editor(|editor, window, cx| { editor.join_lines_impl(insert_whitespace, window, cx) }); cx.assert_editor_state(&formatdoc! {" {start} * fooˇ */"}); cx.set_state(&formatdoc! {" {start} * fooˇ */"}); cx.update_editor(|editor, window, cx| { editor.join_lines_impl(insert_whitespace, window, cx) }); cx.assert_editor_state(&formatdoc! {" {start} * fooˇ{separator}*/"}); } } } #[gpui::test] async fn test_custom_newlines_cause_no_false_positive_diffs( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("Line 0\r\nLine 1\rˇ\nLine 2\r\nLine 3"); cx.set_head_text("Line 0\r\nLine 1\r\nLine 2\r\nLine 3"); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( snapshot .buffer_snapshot() .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) .collect::>(), Vec::new(), "Should not have any diffs for files with custom newlines" ); }); } #[gpui::test] async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Test sort_lines_case_insensitive() cx.set_state(indoc! {" «z y x Z Y Xˇ» "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_insensitive(&SortLinesCaseInsensitive, window, cx) }); cx.assert_editor_state(indoc! {" «x X y Y z Zˇ» "}); // Test sort_lines_by_length() // // Demonstrates: // - ∞ is 3 bytes UTF-8, but sorted by its char count (1) // - sort is stable cx.set_state(indoc! {" «123 æ 12 ∞ 1 æˇ» "}); cx.update_editor(|e, window, cx| e.sort_lines_by_length(&SortLinesByLength, window, cx)); cx.assert_editor_state(indoc! {" «æ ∞ 1 æ 12 123ˇ» "}); // Test reverse_lines() cx.set_state(indoc! {" «5 4 3 2 1ˇ» "}); cx.update_editor(|e, window, cx| e.reverse_lines(&ReverseLines, window, cx)); cx.assert_editor_state(indoc! {" «1 2 3 4 5ˇ» "}); // Skip testing shuffle_line() // From here on out, test more complex cases of manipulate_immutable_lines() with a single driver method: sort_lines_case_sensitive() // Since all methods calling manipulate_immutable_lines() are doing the exact same general thing (reordering lines) // Don't manipulate when cursor is on single line, but expand the selection cx.set_state(indoc! {" ddˇdd ccc bb a "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «ddddˇ» ccc bb a "}); // Basic manipulate case // Start selection moves to column 0 // End of selection shrinks to fit shorter line cx.set_state(indoc! {" dd«d ccc bb aaaaaˇ» "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «aaaaa bb ccc dddˇ» "}); // Manipulate case with newlines cx.set_state(indoc! {" dd«d ccc bb aaaaa ˇ» "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" « aaaaa bb ccc dddˇ» "}); // Adding new line cx.set_state(indoc! {" aa«a bbˇ»b "}); cx.update_editor(|e, window, cx| { e.manipulate_immutable_lines(window, cx, |lines| lines.push("added_line")) }); cx.assert_editor_state(indoc! {" «aaa bbb added_lineˇ» "}); // Removing line cx.set_state(indoc! {" aa«a bbbˇ» "}); cx.update_editor(|e, window, cx| { e.manipulate_immutable_lines(window, cx, |lines| { lines.pop(); }) }); cx.assert_editor_state(indoc! {" «aaaˇ» "}); // Removing all lines cx.set_state(indoc! {" aa«a bbbˇ» "}); cx.update_editor(|e, window, cx| { e.manipulate_immutable_lines(window, cx, |lines| { lines.drain(..); }) }); cx.assert_editor_state(indoc! {" ˇ "}); } #[gpui::test] async fn test_unique_lines_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Consider continuous selection as single selection cx.set_state(indoc! {" Aaa«aa cˇ»c«c bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «Aaaaa ccc bb aaaaaˇ» "}); cx.set_state(indoc! {" Aaa«aa cˇ»c«c bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, window, cx) }); cx.assert_editor_state(indoc! {" «Aaaaa ccc bbˇ» "}); // Consider non continuous selection as distinct dedup operations cx.set_state(indoc! {" «aaaaa bb aaaaa aaaaaˇ» aaa«aaˇ» "}); cx.update_editor(|e, window, cx| { e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «aaaaa bbˇ» «aaaaaˇ» "}); } #[gpui::test] async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" «Aaa aAa Aaaˇ» "}); cx.update_editor(|e, window, cx| { e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «Aaa aAaˇ» "}); cx.set_state(indoc! {" «Aaa aAa aaAˇ» "}); cx.update_editor(|e, window, cx| { e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, window, cx) }); cx.assert_editor_state(indoc! {" «Aaaˇ» "}); } #[gpui::test] async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let js_language = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), wrap_characters: Some(language::WrapCharactersConfig { start_prefix: "<".into(), start_suffix: ">".into(), end_prefix: "".into(), }), ..LanguageConfig::default() }, None, )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); cx.set_state(indoc! {" «testˇ» "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" <«ˇ»>test "}); cx.set_state(indoc! {" «test testˇ» "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" <«ˇ»>test test "}); cx.set_state(indoc! {" teˇst "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" te<«ˇ»>st "}); } #[gpui::test] async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let js_language = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), wrap_characters: Some(language::WrapCharactersConfig { start_prefix: "<".into(), start_suffix: ">".into(), end_prefix: "".into(), }), ..LanguageConfig::default() }, None, )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); cx.set_state(indoc! {" «testˇ» «testˇ» «testˇ» «testˇ» "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" <«ˇ»>test <«ˇ»>test <«ˇ»>test <«ˇ»>test "}); cx.set_state(indoc! {" «test testˇ» «test testˇ» "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" <«ˇ»>test test <«ˇ»>test test "}); } #[gpui::test] async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let plaintext_language = Arc::new(Language::new( LanguageConfig { name: "Plain Text".into(), ..LanguageConfig::default() }, None, )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); cx.set_state(indoc! {" «testˇ» "}); cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); cx.assert_editor_state(indoc! {" «testˇ» "}); } #[gpui::test] async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Manipulate with multiple selections on a single line cx.set_state(indoc! {" dd«dd cˇ»c«c bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «aaaaa bb ccc ddddˇ» "}); // Manipulate with multiple disjoin selections cx.set_state(indoc! {" 5« 4 3 2 1ˇ» dd«dd ccc bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) }); cx.assert_editor_state(indoc! {" «1 2 3 4 5ˇ» «aaaaa bb ccc ddddˇ» "}); // Adding lines on each selection cx.set_state(indoc! {" 2« 1ˇ» bb«bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.manipulate_immutable_lines(window, cx, |lines| lines.push("added line")) }); cx.assert_editor_state(indoc! {" «2 1 added lineˇ» «bbbb aaaaa added lineˇ» "}); // Removing lines on each selection cx.set_state(indoc! {" 2« 1ˇ» bb«bb aaaˇ»aa "}); cx.update_editor(|e, window, cx| { e.manipulate_immutable_lines(window, cx, |lines| { lines.pop(); }) }); cx.assert_editor_state(indoc! {" «2ˇ» «bbbbˇ» "}); } #[gpui::test] async fn test_convert_indentation_to_spaces(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(3) }); let mut cx = EditorTestContext::new(cx).await; // MULTI SELECTION // Ln.1 "«" tests empty lines // Ln.9 tests just leading whitespace cx.set_state(indoc! {" « abc // No indentationˇ» «\tabc // 1 tabˇ» \t\tabc « ˇ» // 2 tabs \t ab«c // Tab followed by space \tabc // Space followed by tab (3 spaces should be the result) \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) abˇ»ˇc ˇ ˇ // Already space indented« \t \tabc\tdef // Only the leading tab is manipulatedˇ» "}); cx.update_editor(|e, window, cx| { e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); }); cx.assert_editor_state( indoc! {" « abc // No indentation abc // 1 tab abc // 2 tabs abc // Tab followed by space abc // Space followed by tab (3 spaces should be the result) abc // Mixed indentation (tab conversion depends on the column) abc // Already space indented · abc\tdef // Only the leading tab is manipulatedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); // Test on just a few lines, the others should remain unchanged // Only lines (3, 5, 10, 11) should change cx.set_state( indoc! {" · abc // No indentation \tabcˇ // 1 tab \t\tabc // 2 tabs \t abcˇ // Tab followed by space \tabc // Space followed by tab (3 spaces should be the result) \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) abc // Already space indented «\t \tabc\tdef // Only the leading tab is manipulatedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); cx.update_editor(|e, window, cx| { e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); }); cx.assert_editor_state( indoc! {" · abc // No indentation « abc // 1 tabˇ» \t\tabc // 2 tabs « abc // Tab followed by spaceˇ» \tabc // Space followed by tab (3 spaces should be the result) \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) abc // Already space indented « · abc\tdef // Only the leading tab is manipulatedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); // SINGLE SELECTION // Ln.1 "«" tests empty lines // Ln.9 tests just leading whitespace cx.set_state(indoc! {" « abc // No indentation \tabc // 1 tab \t\tabc // 2 tabs \t abc // Tab followed by space \tabc // Space followed by tab (3 spaces should be the result) \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) abc // Already space indented \t \tabc\tdef // Only the leading tab is manipulatedˇ» "}); cx.update_editor(|e, window, cx| { e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); }); cx.assert_editor_state( indoc! {" « abc // No indentation abc // 1 tab abc // 2 tabs abc // Tab followed by space abc // Space followed by tab (3 spaces should be the result) abc // Mixed indentation (tab conversion depends on the column) abc // Already space indented · abc\tdef // Only the leading tab is manipulatedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); } #[gpui::test] async fn test_convert_indentation_to_tabs(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(3) }); let mut cx = EditorTestContext::new(cx).await; // MULTI SELECTION // Ln.1 "«" tests empty lines // Ln.11 tests just leading whitespace cx.set_state(indoc! {" « abˇ»ˇc // No indentation abc ˇ ˇ // 1 space (< 3 so dont convert) abc « // 2 spaces (< 3 so dont convert) abc // 3 spaces (convert) abc ˇ» // 5 spaces (1 tab + 2 spaces) «\tˇ»\t«\tˇ»abc // Already tab indented «\t abc // Tab followed by space \tabc // Space followed by tab (should be consumed due to tab) \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) \tˇ» «\t abcˇ» \t ˇˇˇ // Only the leading spaces should be converted "}); cx.update_editor(|e, window, cx| { e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); }); cx.assert_editor_state(indoc! {" « abc // No indentation abc // 1 space (< 3 so dont convert) abc // 2 spaces (< 3 so dont convert) \tabc // 3 spaces (convert) \t abc // 5 spaces (1 tab + 2 spaces) \t\t\tabc // Already tab indented \t abc // Tab followed by space \tabc // Space followed by tab (should be consumed due to tab) \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) \t\t\t \tabc \t // Only the leading spaces should be convertedˇ» "}); // Test on just a few lines, the other should remain unchanged // Only lines (4, 8, 11, 12) should change cx.set_state( indoc! {" · abc // No indentation abc // 1 space (< 3 so dont convert) abc // 2 spaces (< 3 so dont convert) « abc // 3 spaces (convert)ˇ» abc // 5 spaces (1 tab + 2 spaces) \t\t\tabc // Already tab indented \t abc // Tab followed by space \tabc ˇ // Space followed by tab (should be consumed due to tab) \t\t \tabc // Mixed indentation \t \t \t \tabc // Mixed indentation \t \tˇ « abc \t // Only the leading spaces should be convertedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); cx.update_editor(|e, window, cx| { e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); }); cx.assert_editor_state( indoc! {" · abc // No indentation abc // 1 space (< 3 so dont convert) abc // 2 spaces (< 3 so dont convert) «\tabc // 3 spaces (convert)ˇ» abc // 5 spaces (1 tab + 2 spaces) \t\t\tabc // Already tab indented \t abc // Tab followed by space «\tabc // Space followed by tab (should be consumed due to tab)ˇ» \t\t \tabc // Mixed indentation \t \t \t \tabc // Mixed indentation «\t\t\t \tabc \t // Only the leading spaces should be convertedˇ» "} .replace("·", "") .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace ); // SINGLE SELECTION // Ln.1 "«" tests empty lines // Ln.11 tests just leading whitespace cx.set_state(indoc! {" « abc // No indentation abc // 1 space (< 3 so dont convert) abc // 2 spaces (< 3 so dont convert) abc // 3 spaces (convert) abc // 5 spaces (1 tab + 2 spaces) \t\t\tabc // Already tab indented \t abc // Tab followed by space \tabc // Space followed by tab (should be consumed due to tab) \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) \t \t abc \t // Only the leading spaces should be convertedˇ» "}); cx.update_editor(|e, window, cx| { e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); }); cx.assert_editor_state(indoc! {" « abc // No indentation abc // 1 space (< 3 so dont convert) abc // 2 spaces (< 3 so dont convert) \tabc // 3 spaces (convert) \t abc // 5 spaces (1 tab + 2 spaces) \t\t\tabc // Already tab indented \t abc // Tab followed by space \tabc // Space followed by tab (should be consumed due to tab) \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) \t\t\t \tabc \t // Only the leading spaces should be convertedˇ» "}); } #[gpui::test] async fn test_toggle_case(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // If all lower case -> upper case cx.set_state(indoc! {" «hello worldˇ» "}); cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); cx.assert_editor_state(indoc! {" «HELLO WORLDˇ» "}); // If all upper case -> lower case cx.set_state(indoc! {" «HELLO WORLDˇ» "}); cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); cx.assert_editor_state(indoc! {" «hello worldˇ» "}); // If any upper case characters are identified -> lower case // This matches JetBrains IDEs cx.set_state(indoc! {" «hEllo worldˇ» "}); cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); cx.assert_editor_state(indoc! {" «hello worldˇ» "}); } #[gpui::test] async fn test_convert_to_sentence_case(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" «implement-windows-supportˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) }); cx.assert_editor_state(indoc! {" «Implement windows supportˇ» "}); } #[gpui::test] async fn test_convert_to_base64(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Encode a plain text selection cx.set_state(indoc! {" «helloˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_base64(&ConvertToBase64, window, cx)); cx.assert_editor_state(indoc! {" «aGVsbG8=ˇ» "}); // Decode a valid base64 selection cx.set_state(indoc! {" «aGVsbG8=ˇ» "}); cx.update_editor(|e, window, cx| e.convert_from_base64(&ConvertFromBase64, window, cx)); cx.assert_editor_state(indoc! {" «helloˇ» "}); // Decode invalid base64 — should leave text unchanged cx.set_state(indoc! {" «not!!!ˇ» "}); cx.update_editor(|e, window, cx| e.convert_from_base64(&ConvertFromBase64, window, cx)); cx.assert_editor_state(indoc! {" «not!!!ˇ» "}); } #[gpui::test] fn test_manipulate_text_handles_cross_excerpt_edit_that_applies_differently( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| { let mut buffer = Buffer::local("ab", cx); // The selected multibuffer range starts in this excerpt, but edits to // it are skipped because the underlying buffer is read-only. buffer.set_capability(language::Capability::ReadOnly, cx); buffer }); let buffer_2 = cx.new(|cx| Buffer::local("cd", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(0, 2)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(0, 2)], 0, cx, ); multibuffer }); cx.add_window(|window, cx| { let mut editor = build_editor(multibuffer, window, cx); let len = editor.buffer().read(cx).len(cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([MultiBufferOffset(0)..len]) }); // No-op transformations should not be sent through `MultiBuffer::edit`. editor.manipulate_text(window, cx, |text| text.to_string()); assert_eq!(buffer_1.read(cx).text(), "ab"); assert_eq!(buffer_2.read(cx).text(), "cd"); // A real replacement can apply differently than requested; selection // remapping should follow the actual edit instead of predicted offsets. editor.manipulate_text(window, cx, |_| "replacement".to_string()); assert_eq!(buffer_1.read(cx).text(), "ab"); assert_eq!(buffer_2.read(cx).text(), ""); editor }); } #[gpui::test] async fn test_manipulate_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Test convert_to_upper_case() cx.set_state(indoc! {" «hello worldˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); cx.assert_editor_state(indoc! {" «HELLO WORLDˇ» "}); // Test convert_to_lower_case() cx.set_state(indoc! {" «HELLO WORLDˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_lower_case(&ConvertToLowerCase, window, cx)); cx.assert_editor_state(indoc! {" «hello worldˇ» "}); // Test multiple line, single selection case cx.set_state(indoc! {" «The quick brown fox jumps over the lazy dogˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); cx.assert_editor_state(indoc! {" «The Quick Brown Fox Jumps Over The Lazy Dogˇ» "}); // Test multiple line, single selection case cx.set_state(indoc! {" «The quick brown fox jumps over the lazy dogˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) }); cx.assert_editor_state(indoc! {" «TheQuickBrown FoxJumpsOver TheLazyDogˇ» "}); // From here on out, test more complex cases of manipulate_text() // Test no selection case - should affect words cursors are in // Cursor at beginning, middle, and end of word cx.set_state(indoc! {" ˇhello big beauˇtiful worldˇ "}); cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); cx.assert_editor_state(indoc! {" «HELLOˇ» big «BEAUTIFULˇ» «WORLDˇ» "}); // Test multiple selections on a single line and across multiple lines cx.set_state(indoc! {" «Theˇ» quick «brown foxˇ» jumps «overˇ» the «lazyˇ» dog "}); cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); cx.assert_editor_state(indoc! {" «THEˇ» quick «BROWN FOXˇ» jumps «OVERˇ» the «LAZYˇ» dog "}); // Test case where text length grows cx.set_state(indoc! {" «tschüߡ» "}); cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); cx.assert_editor_state(indoc! {" «TSCHÜSSˇ» "}); // Test to make sure we don't crash when text shrinks cx.set_state(indoc! {" aaa_bbbˇ "}); cx.update_editor(|e, window, cx| { e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) }); cx.assert_editor_state(indoc! {" «aaaBbbˇ» "}); // Test to make sure we all aware of the fact that each word can grow and shrink // Final selections should be aware of this fact cx.set_state(indoc! {" aaa_bˇbb bbˇb_ccc ˇccc_ddd "}); cx.update_editor(|e, window, cx| { e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) }); cx.assert_editor_state(indoc! {" «aaaBbbˇ» «bbbCccˇ» «cccDddˇ» "}); cx.set_state(indoc! {" «hElLo, WoRld!ˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_opposite_case(&ConvertToOppositeCase, window, cx) }); cx.assert_editor_state(indoc! {" «HeLlO, wOrLD!ˇ» "}); // Test that case conversions backed by `to_case` preserve leading/trailing whitespace. cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); cx.assert_editor_state(indoc! {" « Hello Worldˇ» "}); cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) }); cx.assert_editor_state(indoc! {" « HelloWorldˇ» "}); cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) }); cx.assert_editor_state(indoc! {" « helloWorldˇ» "}); cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); cx.assert_editor_state(indoc! {" « hello_worldˇ» "}); cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_kebab_case(&ConvertToKebabCase, window, cx)); cx.assert_editor_state(indoc! {" « hello-worldˇ» "}); cx.set_state(indoc! {" « hello worldˇ» "}); cx.update_editor(|e, window, cx| { e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) }); cx.assert_editor_state(indoc! {" « Hello worldˇ» "}); cx.set_state(indoc! {" « hello world\t\tˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); cx.assert_editor_state(indoc! {" « Hello World\t\tˇ» "}); cx.set_state(indoc! {" « hello world\t\tˇ» "}); cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); cx.assert_editor_state(indoc! {" « hello_world\t\tˇ» "}); cx.set_state(indoc! {" «hello world ˇ»goodbye "}); cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); cx.assert_editor_state(indoc! {" «hello_world ˇ»goodbye "}); // Test selections with `line_mode() = true`. cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.set_state(indoc! {" «The quick brown fox jumps over tˇ»he lazy dog "}); cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); cx.assert_editor_state(indoc! {" «THE QUICK BROWN FOX JUMPS OVER THE LAZY DOGˇ» "}); } #[gpui::test] fn test_duplicate_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), ]) }); editor.duplicate_line_down(&DuplicateLineDown, window, cx); assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(6), 0), ] ); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), ]) }); editor.duplicate_line_down(&DuplicateLineDown, window, cx); assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(4), 1), DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(5), 1), ] ); }); // With `duplicate_line_up` the selections move to the duplicated lines, // which are inserted above the original lines let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), ]) }); editor.duplicate_line_up(&DuplicateLineUp, window, cx); assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), ]) }); editor.duplicate_line_up(&DuplicateLineUp, window, cx); assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), ] ); }); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), ]) }); editor.duplicate_selection(&DuplicateSelection, window, cx); assert_eq!(editor.display_text(cx), "abc\ndbc\ndef\ngf\nghi\n"); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 1), ] ); }); } #[gpui::test] async fn test_rotate_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Rotate text selections (horizontal) cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); // Rotate text selections (vertical) cx.set_state(indoc! {" x=«1ˇ» y=«2ˇ» z=«3ˇ» "}); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state(indoc! {" x=«3ˇ» y=«1ˇ» z=«2ˇ» "}); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state(indoc! {" x=«1ˇ» y=«2ˇ» z=«3ˇ» "}); // Rotate text selections (vertical, different lengths) cx.set_state(indoc! {" x=\"«ˇ»\" y=\"«aˇ»\" z=\"«aaˇ»\" "}); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state(indoc! {" x=\"«aaˇ»\" y=\"«ˇ»\" z=\"«aˇ»\" "}); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state(indoc! {" x=\"«ˇ»\" y=\"«aˇ»\" z=\"«aaˇ»\" "}); // Rotate whole lines (cursor positions preserved) cx.set_state(indoc! {" ˇline123 liˇne23 line3ˇ "}); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state(indoc! {" line3ˇ ˇline123 liˇne23 "}); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state(indoc! {" ˇline123 liˇne23 line3ˇ "}); // Rotate whole lines, multiple cursors per line (positions preserved) cx.set_state(indoc! {" ˇliˇne123 ˇline23 ˇline3 "}); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state(indoc! {" ˇline3 ˇliˇne123 ˇline23 "}); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state(indoc! {" ˇliˇne123 ˇline23 ˇline3 "}); } #[gpui::test] async fn test_rotate_selections_nonconsecutive_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇline1 line2 liˇne3 line4ˇ "}); cx.update_editor(|e, window, cx| { e.rotate_selections_forward(&RotateSelectionsForward, window, cx) }); cx.assert_editor_state(indoc! {" line4ˇ line2 ˇline1 liˇne3 "}); cx.update_editor(|e, window, cx| { e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) }); cx.assert_editor_state(indoc! {" ˇline1 line2 liˇne3 line4ˇ "}); } #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.fold_creases( vec![ Crease::simple(Point::new(0, 2)..Point::new(1, 2), FoldPlaceholder::test()), Crease::simple(Point::new(2, 3)..Point::new(4, 1), FoldPlaceholder::test()), Crease::simple(Point::new(7, 0)..Point::new(8, 4), FoldPlaceholder::test()), ], true, window, cx, ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2), ]) }); assert_eq!( editor.display_text(cx), "aa⋯bbb\nccc⋯eeee\nfffff\nggggg\n⋯i\njjjjj" ); editor.move_line_up(&MoveLineUp, window, cx); assert_eq!( editor.display_text(cx), "aa⋯bbb\nccc⋯eeee\nggggg\n⋯i\njjjjj\nfffff" ); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(4), 2) ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_line_down(&MoveLineDown, window, cx); assert_eq!( editor.display_text(cx), "ccc⋯eeee\naa⋯bbb\nfffff\nggggg\n⋯i\njjjjj" ); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2) ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_line_down(&MoveLineDown, window, cx); assert_eq!( editor.display_text(cx), "ccc⋯eeee\nfffff\naa⋯bbb\nggggg\n⋯i\njjjjj" ); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2) ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.move_line_up(&MoveLineUp, window, cx); assert_eq!( editor.display_text(cx), "ccc⋯eeee\naa⋯bbb\nggggg\n⋯i\njjjjj\nfffff" ); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(4), 2) ] ); }); } #[gpui::test] fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.fold_creases( vec![Crease::simple( Point::new(6, 4)..Point::new(7, 4), FoldPlaceholder::test(), )], true, window, cx, ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(7, 4)..Point::new(7, 4)]) }); assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc"); editor.move_line_up(&MoveLineUp, window, cx); let buffer_text = editor.buffer.read(cx).snapshot(cx).text(); assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc"); }); } #[gpui::test] fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { let snapshot = editor.buffer.read(cx).snapshot(cx); editor.insert_blocks( [BlockProperties { style: BlockStyle::Fixed, placement: BlockPlacement::Below(snapshot.anchor_after(Point::new(2, 0))), height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, }], Some(Autoscroll::fit()), cx, ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) }); editor.move_line_down(&MoveLineDown, window, cx); }); } #[gpui::test] async fn test_selections_and_replace_blocks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( &" ˇzero one two three four five " .unindent(), ); // Create a four-line block that replaces three lines of text. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let snapshot = &snapshot.buffer_snapshot(); let placement = BlockPlacement::Replace( snapshot.anchor_after(Point::new(1, 0))..=snapshot.anchor_after(Point::new(3, 0)), ); editor.insert_blocks( [BlockProperties { placement, height: Some(4), style: BlockStyle::Sticky, render: Arc::new(|_| gpui::div().into_any_element()), priority: 0, }], None, cx, ); }); // Move down so that the cursor touches the block. cx.update_editor(|editor, window, cx| { editor.move_down(&Default::default(), window, cx); }); cx.assert_editor_state( &" zero «one two threeˇ» four five " .unindent(), ); // Move down past the block. cx.update_editor(|editor, window, cx| { editor.move_down(&Default::default(), window, cx); }); cx.assert_editor_state( &" zero one two three ˇfour five " .unindent(), ); } #[gpui::test] fn test_transpose(cx: &mut TestAppContext) { init_test(cx, |_| {}); _ = cx.add_window(|window, cx| { let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(2)..MultiBufferOffset(2)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bca"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(3)..MultiBufferOffset(3)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(3)..MultiBufferOffset(3)] ); editor }); _ = cx.add_window(|window, cx| { let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(3)..MultiBufferOffset(3)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acb\nde"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(3)..MultiBufferOffset(3)] ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(5)..MultiBufferOffset(5)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbde\n"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(6)..MultiBufferOffset(6)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(6)..MultiBufferOffset(6)] ); editor }); _ = cx.add_window(|window, cx| { let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffset(1)..MultiBufferOffset(1), MultiBufferOffset(2)..MultiBufferOffset(2), MultiBufferOffset(4)..MultiBufferOffset(4), ]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bacd\ne"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ MultiBufferOffset(2)..MultiBufferOffset(2), MultiBufferOffset(3)..MultiBufferOffset(3), MultiBufferOffset(5)..MultiBufferOffset(5) ] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ MultiBufferOffset(3)..MultiBufferOffset(3), MultiBufferOffset(4)..MultiBufferOffset(4), MultiBufferOffset(6)..MultiBufferOffset(6) ] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcda\ne"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ MultiBufferOffset(4)..MultiBufferOffset(4), MultiBufferOffset(6)..MultiBufferOffset(6) ] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ MultiBufferOffset(4)..MultiBufferOffset(4), MultiBufferOffset(6)..MultiBufferOffset(6) ] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcaed\n"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ MultiBufferOffset(5)..MultiBufferOffset(5), MultiBufferOffset(6)..MultiBufferOffset(6) ] ); editor }); _ = cx.add_window(|window, cx| { let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), window, cx); editor.set_style(EditorStyle::default(), window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(8)..MultiBufferOffset(8)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀✋🍐"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(11)..MultiBufferOffset(11)] ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [MultiBufferOffset(11)..MultiBufferOffset(11)] ); editor }); } #[gpui::test] async fn test_rewrap(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.languages.0.extend([ ( "Markdown".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::Anywhere), preferred_line_length: Some(40), ..Default::default() }, ), ( "Plain Text".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::Anywhere), preferred_line_length: Some(40), ..Default::default() }, ), ( "C++".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, ), ( "Python".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, ), ( "Rust".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, ), ]) }); let mut cx = EditorTestContext::new(cx).await; let cpp_language = Arc::new(Language::new( LanguageConfig { name: "C++".into(), line_comments: vec!["// ".into()], ..LanguageConfig::default() }, None, )); let python_language = Arc::new(Language::new( LanguageConfig { name: "Python".into(), line_comments: vec!["# ".into()], ..LanguageConfig::default() }, None, )); let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), rewrap_prefixes: vec![ regex::Regex::new("\\d+\\.\\s+").unwrap(), regex::Regex::new("[-*+]\\s+").unwrap(), ], ..LanguageConfig::default() }, None, )); let rust_language = Arc::new( Language::new( LanguageConfig { name: "Rust".into(), line_comments: vec!["// ".into(), "/// ".into()], ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") .unwrap(), ); let plaintext_language = Arc::new(Language::new( LanguageConfig { name: "Plain Text".into(), ..LanguageConfig::default() }, None, )); // Test basic rewrapping of a long line with a cursor assert_rewrap( indoc! {" // ˇThis is a long comment that needs to be wrapped. "}, indoc! {" // ˇThis is a long comment that needs to // be wrapped. "}, cpp_language.clone(), &mut cx, ); // Test rewrapping a full selection assert_rewrap( indoc! {" «// This selected long comment needs to be wrapped.ˇ»" }, indoc! {" «// This selected long comment needs to // be wrapped.ˇ»" }, cpp_language.clone(), &mut cx, ); // Test multiple cursors on different lines within the same paragraph are preserved after rewrapping assert_rewrap( indoc! {" // ˇThis is the first line. // Thisˇ is the second line. // This is the thirdˇ line, all part of one paragraph. "}, indoc! {" // ˇThis is the first line. Thisˇ is the // second line. This is the thirdˇ line, // all part of one paragraph. "}, cpp_language.clone(), &mut cx, ); // Test multiple cursors in different paragraphs trigger separate rewraps assert_rewrap( indoc! {" // ˇThis is the first paragraph, first line. // ˇThis is the first paragraph, second line. // ˇThis is the second paragraph, first line. // ˇThis is the second paragraph, second line. "}, indoc! {" // ˇThis is the first paragraph, first // line. ˇThis is the first paragraph, // second line. // ˇThis is the second paragraph, first // line. ˇThis is the second paragraph, // second line. "}, cpp_language.clone(), &mut cx, ); // Test that change in comment prefix (e.g., `//` to `///`) trigger separate rewraps assert_rewrap( indoc! {" «// A regular long long comment to be wrapped. /// A documentation long comment to be wrapped.ˇ» "}, indoc! {" «// A regular long long comment to be // wrapped. /// A documentation long comment to be /// wrapped.ˇ» "}, rust_language.clone(), &mut cx, ); // Test that change in indentation level trigger separate rewraps assert_rewrap( indoc! {" fn foo() { «// This is a long comment at the base indent. // This is a long comment at the next indent.ˇ» } "}, indoc! {" fn foo() { «// This is a long comment at the // base indent. // This is a long comment at the // next indent.ˇ» } "}, rust_language.clone(), &mut cx, ); // Test that different comment prefix characters (e.g., '#') are handled correctly assert_rewrap( indoc! {" # ˇThis is a long comment using a pound sign. "}, indoc! {" # ˇThis is a long comment using a pound # sign. "}, python_language, &mut cx, ); // Test rewrapping only affects comments, not code even when selected assert_rewrap( indoc! {" «/// This doc comment is long and should be wrapped. fn my_func(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) {}ˇ» "}, indoc! {" «/// This doc comment is long and should /// be wrapped. fn my_func(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) {}ˇ» "}, rust_language.clone(), &mut cx, ); // Test that rewrapping works in Markdown documents where `allow_rewrap` is `Anywhere` assert_rewrap( indoc! {" # Header A long long long line of markdown text to wrap.ˇ "}, indoc! {" # Header A long long long line of markdown text to wrap.ˇ "}, markdown_language.clone(), &mut cx, ); // Test that rewrapping boundary works and preserves relative indent for Markdown documents assert_rewrap( indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, markdown_language.clone(), &mut cx, ); // Test that rewrapping add indents for rewrapping boundary if not exists already. assert_rewrap( indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, markdown_language.clone(), &mut cx, ); // Test that rewrapping maintain indents even when they already exists. assert_rewrap( indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, indoc! {" «1. This is a numbered list item that is very long and needs to be wrapped properly. 2. This is a numbered list item that is very long and needs to be wrapped properly. - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» "}, markdown_language.clone(), &mut cx, ); // Test that empty selection rewrap on a numbered list item does not merge adjacent items assert_rewrap( indoc! {" 1. This is the first numbered list item that is very long and needs to be wrapped properly. 2. ˇThis is the second numbered list item that is also very long and needs to be wrapped. 3. This is the third numbered list item, shorter. "}, indoc! {" 1. This is the first numbered list item that is very long and needs to be wrapped properly. 2. ˇThis is the second numbered list item that is also very long and needs to be wrapped. 3. This is the third numbered list item, shorter. "}, markdown_language.clone(), &mut cx, ); // Test that empty selection rewrap on a bullet list item does not merge adjacent items assert_rewrap( indoc! {" - This is the first bullet item that is very long and needs wrapping properly here. - ˇThis is the second bullet item that is also very long and needs to be wrapped. - This is the third bullet item, shorter. "}, indoc! {" - This is the first bullet item that is very long and needs wrapping properly here. - ˇThis is the second bullet item that is also very long and needs to be wrapped. - This is the third bullet item, shorter. "}, markdown_language, &mut cx, ); // Test that rewrapping works in plain text where `allow_rewrap` is `Anywhere` assert_rewrap( indoc! {" ˇThis is a very long line of plain text that will be wrapped. "}, indoc! {" ˇThis is a very long line of plain text that will be wrapped. "}, plaintext_language.clone(), &mut cx, ); // Test that non-commented code acts as a paragraph boundary within a selection assert_rewrap( indoc! {" «// This is the first long comment block to be wrapped. fn my_func(a: u32); // This is the second long comment block to be wrapped.ˇ» "}, indoc! {" «// This is the first long comment block // to be wrapped. fn my_func(a: u32); // This is the second long comment block // to be wrapped.ˇ» "}, rust_language, &mut cx, ); // Test rewrapping multiple selections, including ones with blank lines or tabs assert_rewrap( indoc! {" «ˇThis is a very long line that will be wrapped. This is another paragraph in the same selection.» «\tThis is a very long indented line that will be wrapped.ˇ» "}, indoc! {" «ˇThis is a very long line that will be wrapped. This is another paragraph in the same selection.» «\tThis is a very long indented line \tthat will be wrapped.ˇ» "}, plaintext_language, &mut cx, ); // Test that an empty comment line acts as a paragraph boundary assert_rewrap( indoc! {" // ˇThis is a long comment that will be wrapped. // // And this is another long comment that will also be wrapped.ˇ "}, indoc! {" // ˇThis is a long comment that will be // wrapped. // // And this is another long comment that // will also be wrapped.ˇ "}, cpp_language, &mut cx, ); #[track_caller] fn assert_rewrap( unwrapped_text: &str, wrapped_text: &str, language: Arc, cx: &mut EditorTestContext, ) { cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(unwrapped_text); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(wrapped_text); } } #[gpui::test] async fn test_rewrap_block_comments(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.languages.0.extend([( "Rust".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, )]) }); let mut cx = EditorTestContext::new(cx).await; let rust_lang = Arc::new( Language::new( LanguageConfig { name: "Rust".into(), line_comments: vec!["// ".into()], block_comment: Some(BlockCommentConfig { start: "/*".into(), end: "*/".into(), prefix: "* ".into(), tab_size: 1, }), documentation_comment: Some(BlockCommentConfig { start: "/**".into(), end: "*/".into(), prefix: "* ".into(), tab_size: 1, }), ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("[(line_comment) (block_comment)] @comment.inclusive") .unwrap(), ); // regular block comment assert_rewrap( indoc! {" /* *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // indent is respected assert_rewrap( indoc! {" {} /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" {} /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // short block comments with inline delimiters assert_rewrap( indoc! {" /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // multiline block comment with inline start/end delimiters assert_rewrap( indoc! {" /*ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, indoc! {" /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // block comment rewrap still respects paragraph bounds assert_rewrap( indoc! {" /* *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. * * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. * * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // documentation comments assert_rewrap( indoc! {" /**ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /** *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" /** *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /** *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // different, adjacent comments assert_rewrap( indoc! {" /** *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. "}, indoc! {" /** *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ //ˇ Lorem ipsum dolor sit amet, // consectetur adipiscing elit. "}, rust_lang.clone(), &mut cx, ); // selection w/ single short block comment assert_rewrap( indoc! {" «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» "}, indoc! {" «/* * Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ˇ» "}, rust_lang.clone(), &mut cx, ); // rewrapping a single comment w/ abutting comments assert_rewrap( indoc! {" /* ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, indoc! {" /* * ˇLorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ "}, rust_lang.clone(), &mut cx, ); // selection w/ non-abutting short block comments assert_rewrap( indoc! {" «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» "}, indoc! {" «/* * Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* * Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ˇ» "}, rust_lang.clone(), &mut cx, ); // selection of multiline block comments assert_rewrap( indoc! {" «/* Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ˇ» "}, indoc! {" «/* * Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ˇ» "}, rust_lang.clone(), &mut cx, ); // partial selection of multiline block comments assert_rewrap( indoc! {" «/* Lorem ipsum dolor sit amet,ˇ» * consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, «* consectetur adipiscing elit. */ˇ» "}, indoc! {" «/* * Lorem ipsum dolor sit amet,ˇ» * consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, «* consectetur adipiscing elit. */ˇ» "}, rust_lang.clone(), &mut cx, ); // selection w/ abutting short block comments // TODO: should not be combined; should rewrap as 2 comments assert_rewrap( indoc! {" «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» "}, // desired behavior: // indoc! {" // «/* // * Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // */ // /* // * Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // */ˇ» // "}, // actual behaviour: indoc! {" «/* * Lorem ipsum dolor sit amet, * consectetur adipiscing elit. Lorem * ipsum dolor sit amet, consectetur * adipiscing elit. */ˇ» "}, rust_lang.clone(), &mut cx, ); // TODO: same as above, but with delimiters on separate line // assert_rewrap( // indoc! {" // «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. // */ // /* // * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» // "}, // // desired: // // indoc! {" // // «/* // // * Lorem ipsum dolor sit amet, // // * consectetur adipiscing elit. // // */ // // /* // // * Lorem ipsum dolor sit amet, // // * consectetur adipiscing elit. // // */ˇ» // // "}, // // actual: (but with trailing w/s on the empty lines) // indoc! {" // «/* // * Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // * // */ // /* // * // * Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // */ˇ» // "}, // rust_lang.clone(), // &mut cx, // ); // TODO these are unhandled edge cases; not correct, just documenting known issues assert_rewrap( indoc! {" /* //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /* //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ /*ˇ Lorem ipsum dolor sit amet */ /* consectetur adipiscing elit. */ "}, // desired: // indoc! {" // /* // *ˇ Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // */ // /* // *ˇ Lorem ipsum dolor sit amet, // * consectetur adipiscing elit. // */ // /* // *ˇ Lorem ipsum dolor sit amet // */ /* consectetur adipiscing elit. */ // "}, // actual: indoc! {" /* //ˇ Lorem ipsum dolor sit amet, // consectetur adipiscing elit. */ /* * //ˇ Lorem ipsum dolor sit amet, * consectetur adipiscing elit. */ /* *ˇ Lorem ipsum dolor sit amet */ /* * consectetur adipiscing elit. */ "}, rust_lang, &mut cx, ); #[track_caller] fn assert_rewrap( unwrapped_text: &str, wrapped_text: &str, language: Arc, cx: &mut EditorTestContext, ) { cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(unwrapped_text); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(wrapped_text); } } #[gpui::test] async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.languages.0.extend([( "Go".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, )]) }); let mut cx = EditorTestContext::new(cx).await; let go_lang = languages::language("go", tree_sitter_go::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_lang), cx)); cx.set_state(indoc! {" // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ "}); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(indoc! {" // Lorem ipsum dolor sit amet, // consectetur adipiscing elit.ˇ "}); } #[gpui::test] async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.languages.0.extend([( "C".into(), LanguageSettingsContent { allow_rewrap: Some(language_settings::RewrapBehavior::InComments), preferred_line_length: Some(40), ..Default::default() }, )]) }); let mut cx = EditorTestContext::new(cx).await; let c_lang = languages::language("c", tree_sitter_c::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(c_lang), cx)); cx.set_state(indoc! {" // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ "}); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(indoc! {" // Lorem ipsum dolor sit amet, // consectetur adipiscing elit.ˇ "}); } #[gpui::test] async fn test_hard_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(git_commit_lang()), cx)); cx.update_editor(|editor, _, cx| { editor.set_hard_wrap(Some(14), cx); }); cx.set_state(indoc!( " one two three ˇ " )); cx.simulate_input("four"); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three fourˇ " )); cx.update_editor(|editor, window, cx| { editor.newline(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three four ˇ " )); cx.simulate_input("five"); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three four fiveˇ " )); cx.update_editor(|editor, window, cx| { editor.newline(&Default::default(), window, cx); }); cx.run_until_parked(); cx.simulate_input("# "); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three four five # ˇ " )); cx.update_editor(|editor, window, cx| { editor.newline(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three four five #\x20 #ˇ " )); cx.simulate_input(" 6"); cx.run_until_parked(); cx.assert_editor_state(indoc!( " one two three four five # # 6ˇ " )); } #[gpui::test] async fn test_cut_line_ends(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {"The quick brownˇ"}); cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); cx.assert_editor_state(indoc! {"The quick brownˇ"}); cx.set_state(indoc! {"The emacs foxˇ"}); cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); cx.assert_editor_state(indoc! {"The emacs foxˇ"}); cx.set_state(indoc! {" The quick« brownˇ» fox jumps overˇ the lazy dog"}); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); cx.assert_editor_state(indoc! {" The quickˇ ˇthe lazy dog"}); cx.set_state(indoc! {" The quick« brownˇ» fox jumps overˇ the lazy dog"}); cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); cx.assert_editor_state(indoc! {" The quickˇ fox jumps overˇthe lazy dog"}); cx.set_state(indoc! {" The quick« brownˇ» fox jumps overˇ the lazy dog"}); cx.update_editor(|e, window, cx| { e.cut_to_end_of_line( &CutToEndOfLine { stop_at_newlines: true, }, window, cx, ) }); cx.assert_editor_state(indoc! {" The quickˇ fox jumps overˇ the lazy dog"}); cx.set_state(indoc! {" The quick« brownˇ» fox jumps overˇ the lazy dog"}); cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); cx.assert_editor_state(indoc! {" The quickˇ fox jumps overˇthe lazy dog"}); for selection in ["The quick «brownˇ» fox", "The quick «ˇbrown» fox"] { cx.set_state(selection); cx.update_editor(|e, window, cx| { e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx) }); cx.assert_editor_state("The quick ˇ"); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some("brown fox".to_string()) ); } } #[gpui::test] async fn test_kill_ring_cut_accumulates_multi_line_kills(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇone\ntwo"); cx.update_editor(|e, window, cx| { e.kill_ring_cut(&KillRingCut, window, cx); e.kill_ring_cut(&KillRingCut, window, cx); }); cx.update_editor(|e, window, cx| e.kill_ring_yank(&KillRingYank, window, cx)); cx.assert_editor_state("one\nˇtwo"); } #[gpui::test] async fn test_kill_ring_cut_matches_emacs_kill_line_sequence_at_end_of_buffer( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇa\nb\nc"); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, _, cx| { assert_eq!(editor.text(cx), "\nc"); }); cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); cx.update_editor(|editor, _, cx| { assert_eq!(editor.text(cx), "a\nb\nc"); }); } #[gpui::test] async fn test_kill_ring_cut_accumulates_final_line_without_trailing_newline( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇa\nb\nc"); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, _, cx| { assert_eq!(editor.text(cx), ""); }); cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); cx.update_editor(|editor, _, cx| { assert_eq!(editor.text(cx), "a\nb\nc"); }); } #[gpui::test] async fn test_kill_ring_yank_breaks_kill_ring_cut_accumulation(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇa\nb\nc"); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); cx.update_editor(|editor, window, cx| editor.move_to_beginning(&MoveToBeginning, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); cx.update_editor(|editor, _, cx| { assert_eq!(editor.text(cx), "a\nb\nc"); }); } #[gpui::test] async fn test_kill_ring_yank_pastes_accumulated_kill_at_each_cursor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇone\ntwo"); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); cx.set_state("aˇ bˇ"); cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); cx.assert_editor_state("aone\nˇ bone\nˇ"); } #[gpui::test] async fn test_editing_untitled_buffer_redetects_language(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.language_detection = Some(false); }); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let go_language = language("go", tree_sitter_go::LANGUAGE.into()); project.read_with(cx, |project, _| { project.languages().add(rust_lang()); project.languages().add(go_language.clone()); }); let buffer = project .update(cx, |project, cx| project.create_buffer(None, true, cx)) .await .unwrap(); buffer.update(cx, |buffer, _| { buffer.set_content_language_detection_enabled(true); }); let window = cx.add_window(|window, cx| { let editor = Editor::for_buffer(buffer.clone(), None, window, cx); window.focus(&editor.focus_handle(cx), cx); editor }); let editor = window.root(cx).unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), PLAIN_TEXT.name() ); editor.update_in(cx, |editor, window, cx| { editor.insert("fn main() { println!(\"hello\"); }", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), PLAIN_TEXT.name() ); update_test_editor_settings(cx, &|settings| { settings.language_detection = Some(true); }); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.insert("fn main() {}", window, cx); }); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), PLAIN_TEXT.name() ); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.insert("fn main() { println!(\"hello\"); }", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); editor.update_in(cx, |editor, window, cx| { editor.insert(" ", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), PLAIN_TEXT.name() ); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), rust_lang().name() ); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.backspace(&Backspace, window, cx); }); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string( "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hello\") }".to_string(), )); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), rust_lang().name() ); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), go_language.name() ); project.update(cx, |project, cx| { project.set_language_for_buffer(&buffer, PLAIN_TEXT.clone(), cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.insert("fn main() { println!(\"hello\"); }", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); cx.run_until_parked(); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), PLAIN_TEXT.name() ); } #[gpui::test] async fn test_clipboard(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); cx.assert_editor_state("ˇtwo ˇfour ˇsix "); // Paste with three cursors. Each cursor pastes one slice of the clipboard text. cx.set_state("two ˇfour ˇsix ˇ"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); // Paste again but with only two cursors. Since the number of cursors doesn't // match the number of slices in the clipboard, the entire clipboard text // is pasted at each cursor. cx.set_state("ˇtwo one✅ four three six five ˇ"); cx.update_editor(|e, window, cx| { e.handle_input("( ", window, cx); e.paste(&Paste, window, cx); e.handle_input(") ", window, cx); }); cx.assert_editor_state( &([ "( one✅ ", "three ", "five ) ˇtwo one✅ four three six five ( one✅ ", "three ", "five ) ˇ", ] .join("\n")), ); // Cut with three selections, one of which is full-line. cx.set_state(indoc! {" 1«2ˇ»3 4ˇ567 «8ˇ»9"}); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); cx.assert_editor_state(indoc! {" 1ˇ3 ˇ9"}); // Paste with three selections, noticing how the copied selection that was full-line // gets inserted before the second cursor. cx.set_state(indoc! {" 1ˇ3 9ˇ «oˇ»ne"}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" 12ˇ3 4567 9ˇ 8ˇne"}); // Copy with a single cursor only, which writes the whole line into the clipboard. cx.set_state(indoc! {" The quick brown fox juˇmps over the lazy dog"}); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some("fox jumps over\n".to_string()) ); // Paste with three selections, noticing how the copied full-line selection is inserted // before the empty selections but replaces the selection that is non-empty. cx.set_state(indoc! {" Tˇhe quick brown «foˇ»x jumps over tˇhe lazy dog"}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" fox jumps over Tˇhe quick brown fox jumps over ˇx jumps over fox jumps over tˇhe lazy dog"}); } #[gpui::test] async fn test_copy_and_paste_non_empty_selection_followed_by_empty_selection( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" «fooˇ» barˇ "}); cx.update_editor(|editor, window, cx| editor.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("foo\nbar\n".to_string()) ); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" fooˇ bar barˇ "}); } #[gpui::test] async fn test_copy_trim(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( r#" «for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);ˇ» end = cmp::min(max_point, Point::new(end.row + 1, 0)); } "#, ); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Regular copying preserves all indentation selected", ); cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Copying with stripping should strip all leading whitespaces" ); cx.set_state( r#" « for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);ˇ» end = cmp::min(max_point, Point::new(end.row + 1, 0)); } "#, ); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( " for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Regular copying preserves all indentation selected", ); cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Copying with stripping should strip all leading whitespaces, even if some of it was selected" ); cx.set_state( r#" «ˇ for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);» end = cmp::min(max_point, Point::new(end.row + 1, 0)); } "#, ); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( " for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Regular copying for reverse selection works the same", ); cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "Copying with stripping for reverse selection works the same" ); cx.set_state( r#" for selection «in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);ˇ» end = cmp::min(max_point, Point::new(end.row + 1, 0)); } "#, ); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "When selecting past the indent, the copying works as usual", ); cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0);" .to_string() ), "When selecting past the indent, nothing is trimmed" ); cx.set_state( r#" «for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0); ˇ» end = cmp::min(max_point, Point::new(end.row + 1, 0)); } "#, ); cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)), Some( "for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty(); if is_entire_line { start = Point::new(start.row, 0); " .to_string() ), "Copying with stripping should ignore empty lines" ); } #[gpui::test] async fn test_copy_trim_line_mode(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" « fn main() { 1 }ˇ» "}); cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("fn main() {\n 1\n}\n".to_string()) ); let clipboard_selections: Vec = cx .read_from_clipboard() .and_then(|item| item.entries().first().cloned()) .and_then(|entry| match entry { gpui::ClipboardEntry::String(text) => text.metadata_json(), _ => None, }) .expect("should have clipboard selections"); assert_eq!(clipboard_selections.len(), 1); assert!(clipboard_selections[0].is_entire_line); cx.set_state(indoc! {" «fn main() { 1 }ˇ» "}); cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("fn main() {\n 1\n}\n".to_string()) ); let clipboard_selections: Vec = cx .read_from_clipboard() .and_then(|item| item.entries().first().cloned()) .and_then(|entry| match entry { gpui::ClipboardEntry::String(text) => text.metadata_json(), _ => None, }) .expect("should have clipboard selections"); assert_eq!(clipboard_selections.len(), 1); assert!(clipboard_selections[0].is_entire_line); } #[gpui::test] async fn test_clipboard_line_numbers_from_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file( path!("/file.txt"), "first line\nsecond line\nthird line\nfourth line\nfifth line\n".into(), ) .await; let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.txt"), cx) }) .await .unwrap(); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [Point::new(2, 0)..Point::new(5, 0)], 0, cx, ); multibuffer }); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multibuffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { assert_eq!(editor.text(cx), "third line\nfourth line\nfifth line\n"); editor.select_all(&SelectAll, window, cx); editor.copy(&Copy, window, cx); }); let clipboard_selections: Option> = cx .read_from_clipboard() .and_then(|item| item.entries().first().cloned()) .and_then(|entry| match entry { gpui::ClipboardEntry::String(text) => text.metadata_json(), _ => None, }); let selections = clipboard_selections.expect("should have clipboard selections"); assert_eq!(selections.len(), 1); let selection = &selections[0]; assert_eq!( selection.line_range, Some(2..=5), "line range should be from original file (rows 2-5), not multibuffer rows (0-2)" ); } #[gpui::test] async fn test_copy_file_location_from_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "file.txt": "first line\nsecond line\nthird line\nfourth line\nfifth line\n", }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/file.txt"), cx) }) .await .unwrap(); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [Point::new(2, 0)..Point::new(5, 0)], 0, cx, ); multibuffer }); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multibuffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file.txt:3".to_string()), "cursor on the first excerpt row should report its line in the original file" ); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(1, 0)..Point::new(2, 0)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file.txt:4".to_string()), "a selection ending at the start of a row should not include that row" ); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(2, 3)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file.txt:3-5".to_string()), "a multi-row selection should report the original file's line range" ); } #[gpui::test] async fn test_copy_file_name_for_external_file(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "a.txt": "" })).await; fs.insert_tree(path!("/elsewhere"), json!({ "external.csv": "a,b\n" })) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/elsewhere/external.csv"), cx) }) .await .unwrap(); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::for_buffer(buffer, Some(project.clone()), window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.copy_file_name(&CopyFileName, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("external.csv".to_string()), "copy_file_name should work for a file outside the project" ); editor.update_in(cx, |editor, window, cx| { editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("external".to_string()), "copy_file_name_without_extension should work for a file outside the project" ); } #[gpui::test] async fn test_copy_file_location_across_buffers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "one.txt": "one\ntwo\nthree\n", "two.txt": "four\nfive\nsix\n", }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let buffer_1 = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/one.txt"), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/two.txt"), cx) }) .await .unwrap(); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1, [Point::new(1, 0)..Point::new(3, 0)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2, [Point::new(1, 0)..Point::new(3, 0)], 0, cx, ); multibuffer }); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multibuffer, window, cx) }); // The multi-buffer reads "two\nthree\n\nfive\nsix\n", so row 3 is the first // row of the second buffer's excerpt. Select from the first excerpt into it. editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(3, 2)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("two.txt:2".to_string()), "a selection spanning buffers should report the location of the cursor" ); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(3, 2)..Point::new(0, 0)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("one.txt:2".to_string()), "a reversed selection spanning buffers should report the cursor's location in the first buffer" ); } #[gpui::test] async fn test_copy_file_location_with_deleted_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇaaa\nbbb\nccc"); cx.set_head_text("aaa\nXXX\nbbb\nccc"); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); // Multi-buffer rows: 0 "aaa", 1 "XXX" (deleted hunk), 2 "bbb", 3 "ccc" cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(2, 2)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file:2".to_string()), "a selection crossing a deleted hunk should report the location of the cursor" ); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file:1".to_string()) ); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(1, 1)..Point::new(1, 1)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file:1".to_string()), "a cursor inside a deleted hunk has no file location, so the clipboard is unchanged" ); } #[gpui::test] async fn test_copy_file_location_in_singleton_buffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "file.txt": "first line\nsecond line\nthird line\n", }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/file.txt"), cx) }) .await .unwrap(); let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multibuffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file.txt:2".to_string()) ); editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(2, 4)]); }); editor.copy_file_location(&CopyFileLocation, window, cx); }); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), Some("file.txt:1-3".to_string()) ); } #[gpui::test] async fn test_paste_multiline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); // Cut an indented block, without the leading whitespace. cx.set_state(indoc! {" const a: B = ( c(), «d( e, f )ˇ» ); "}); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), ˇ ); "}); // Paste it at the same position. cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), d( e, f )ˇ ); "}); // Paste it at a line with a lower indent level. cx.set_state(indoc! {" ˇ const a: B = ( c(), ); "}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" d( e, f )ˇ const a: B = ( c(), ); "}); // Cut an indented block, with the leading whitespace. cx.set_state(indoc! {" const a: B = ( c(), « d( e, f ) ˇ»); "}); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), ˇ); "}); // Paste it at the same position. cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), d( e, f ) ˇ); "}); // Paste it at a line with a higher indent level. cx.set_state(indoc! {" const a: B = ( c(), d( e, fˇ ) ); "}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), d( e, f d( e, f ) ˇ ) ); "}); // Copy an indented block, starting mid-line cx.set_state(indoc! {" const a: B = ( c(), somethin«g( e, f )ˇ» ); "}); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); // Paste it on a line with a lower indent level cx.update_editor(|e, window, cx| e.move_to_end(&Default::default(), window, cx)); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" const a: B = ( c(), something( e, f ) ); g( e, f )ˇ"}); } #[gpui::test] async fn test_paste_undo_does_not_include_preceding_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|e, _, cx| { e.buffer().update(cx, |buffer, cx| { buffer.set_group_interval(Duration::from_secs(10), cx) }) }); // Type some text cx.set_state("ˇ"); cx.update_editor(|e, window, cx| e.insert("hello", window, cx)); // cx.assert_editor_state("helloˇ"); // Paste some text immediately after typing cx.write_to_clipboard(ClipboardItem::new_string(" world".into())); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("hello worldˇ"); // Undo should only undo the paste, not the preceding typing cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); cx.assert_editor_state("helloˇ"); // Undo again should undo the typing cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); cx.assert_editor_state("ˇ"); } #[gpui::test] async fn test_paste_content_from_other_app(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.write_to_clipboard(ClipboardItem::new_string( " d(\n e\n );\n".into(), )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.run_until_parked(); cx.set_state(indoc! {" fn a() { b(); if c() { ˇ } } "}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" fn a() { b(); if c() { d( e ); ˇ } } "}); cx.set_state(indoc! {" fn a() { b(); ˇ } "}); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state(indoc! {" fn a() { b(); d( e ); ˇ } "}); } #[gpui::test] async fn test_paste_after_closing_documentation_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.write_to_clipboard(ClipboardItem::new_string( "func find() {\n\treturn 1\n}\n".into(), )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); cx.set_state("package test\n\n/**\n * test\n */ˇ"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}\nˇ"); cx.write_to_clipboard(ClipboardItem::new_string( " func find() {\n \treturn 1\n }".into(), )); cx.set_state("package test\n\n/**\n * test\n */ˇ"); cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}ˇ"); } #[gpui::test] async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); cx.write_to_clipboard(ClipboardItem::new_string( " func find() {\n \treturn 1\n }".into(), )); cx.set_state("package test\n\nˇ"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.run_until_parked(); cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ"); // The block moves by however far its first line moved, so a first line that // is indented deeper than the lines below it stays deeper. cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into())); cx.set_state("func test() {\nˇ\n}"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.run_until_parked(); cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}"); } #[gpui::test] async fn test_paste_after_closing_comment_delimiter(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language( Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), cx, ) }); // The closing delimiter's line is not part of the paste, so it keeps its own // indentation even though that indentation differs from the opening line's. cx.write_to_clipboard(ClipboardItem::new_string("x := 1\ny := 2".into())); cx.set_state("package test\n\n/*\n * test\n */ˇ"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); cx.assert_editor_state("package test\n\n/*\n * test\n */x := 1\n y := 2ˇ"); } #[gpui::test] async fn test_paste_multiline_from_other_app_into_matching_cursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); let mut cx = EditorTestContext::new(cx).await; // Paste into 3 cursors: each cursor should receive one line. cx.set_state("ˇ one ˇ two ˇ three"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("alphaˇ one betaˇ two gammaˇ three"); // Paste into 2 cursors: line count doesn't match, so paste entire text at each cursor. cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); cx.set_state("ˇ one ˇ two"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("alpha\nbeta\ngammaˇ one alpha\nbeta\ngammaˇ two"); // Paste into a single cursor: should paste everything as-is. cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); cx.set_state("ˇ one"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("alpha\nbeta\ngammaˇ one"); // Paste with selections: each selection is replaced with its corresponding line. cx.write_to_clipboard(ClipboardItem::new_string("xx\nyy\nzz".into())); cx.set_state("«aˇ» one «bˇ» two «cˇ» three"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("xxˇ one yyˇ two zzˇ three"); } #[gpui::test] fn test_select_all(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 3)] ); }); } #[gpui::test] fn test_select_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 2), ]) }); editor.select_line(&SelectLine, window, cx); // Adjacent line selections should NOT merge (only overlapping ones do) assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(3), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), ] ); }); _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); // Adjacent but not overlapping, so they stay separate assert_eq!( display_ranges(editor, cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), ] ); }); } #[gpui::test] async fn test_split_selection_into_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; #[track_caller] fn test(cx: &mut EditorTestContext, initial_state: &'static str, expected_state: &'static str) { cx.set_state(initial_state); cx.update_editor(|e, window, cx| { e.split_selection_into_lines(&Default::default(), window, cx) }); cx.assert_editor_state(expected_state); } // Selection starts and ends at the middle of lines, left-to-right test( &mut cx, "aa\nb«ˇb\ncc\ndd\ne»e\nff", "aa\nbbˇ\nccˇ\nddˇ\neˇe\nff", ); // Same thing, right-to-left test( &mut cx, "aa\nb«b\ncc\ndd\neˇ»e\nff", "aa\nbbˇ\nccˇ\nddˇ\neˇe\nff", ); // Whole buffer, left-to-right, last line *doesn't* end with newline test( &mut cx, "«ˇaa\nbb\ncc\ndd\nee\nff»", "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ", ); // Same thing, right-to-left test( &mut cx, "«aa\nbb\ncc\ndd\nee\nffˇ»", "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ", ); // Whole buffer, left-to-right, last line ends with newline test( &mut cx, "«ˇaa\nbb\ncc\ndd\nee\nff\n»", "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ\n", ); // Same thing, right-to-left test( &mut cx, "«aa\nbb\ncc\ndd\nee\nff\nˇ»", "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ\n", ); // Starts at the end of a line, ends at the start of another test( &mut cx, "aa\nbb«ˇ\ncc\ndd\nee\n»ff\n", "aa\nbbˇ\nccˇ\nddˇ\neeˇ\nff\n", ); } #[gpui::test] async fn test_split_selection_into_lines_does_not_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let large_body = "\nline".repeat(300); cx.set_state(&format!("«ˇstart{large_body}\nend»")); let initial_scroll_position = cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); cx.update_editor(|editor, window, cx| { editor.split_selection_into_lines(&Default::default(), window, cx); }); let scroll_position_after_split = cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); assert_eq!( initial_scroll_position, scroll_position_after_split, "Scroll position should not change after splitting selection into lines" ); } #[gpui::test] async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx); build_editor(buffer, window, cx) }); // setup _ = editor.update(cx, |editor, window, cx| { editor.fold_creases( vec![ Crease::simple(Point::new(0, 2)..Point::new(1, 2), FoldPlaceholder::test()), Crease::simple(Point::new(2, 3)..Point::new(4, 1), FoldPlaceholder::test()), Crease::simple(Point::new(7, 0)..Point::new(8, 4), FoldPlaceholder::test()), ], true, window, cx, ); assert_eq!( editor.display_text(cx), "aa⋯bbb\nccc⋯eeee\nfffff\nggggg\n⋯i" ); }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4), ]) }); editor.split_selection_into_lines(&Default::default(), window, cx); assert_eq!( editor.display_text(cx), "aaaaa\nbbbbb\nccc⋯eeee\nfffff\nggggg\n⋯i" ); }); EditorTestContext::for_editor(editor, cx) .await .assert_editor_state("aˇaˇaaa\nbbbbb\nˇccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiiiˇ"); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 1) ]) }); editor.split_selection_into_lines(&Default::default(), window, cx); assert_eq!( editor.display_text(cx), "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii" ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5), DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5), DisplayPoint::new(DisplayRow(3), 5)..DisplayPoint::new(DisplayRow(3), 5), DisplayPoint::new(DisplayRow(4), 5)..DisplayPoint::new(DisplayRow(4), 5), DisplayPoint::new(DisplayRow(5), 5)..DisplayPoint::new(DisplayRow(5), 5), DisplayPoint::new(DisplayRow(6), 5)..DisplayPoint::new(DisplayRow(6), 5) ] ); }); EditorTestContext::for_editor(editor, cx) .await .assert_editor_state( "aaaaaˇ\nbbbbbˇ\ncccccˇ\ndddddˇ\neeeeeˇ\nfffffˇ\ngggggˇ\nhhhhh\niiiii", ); } #[gpui::test] async fn test_add_selection_grapheme_columns(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (text, columns) in [ ("e\u{301}x|Z\néé|Zˇ", [4..4, 4..4]), ("e\u{301}x|Z\néé|Zˇ", [4..5, 4..5]), ("éx|Z\ne\u{301}x|Zˇ", [3..3, 4..4]), ("👩\u{200d}💻x|Z\nax|Zˇ", [12..13, 2..3]), ("🇦🇶x|Z\nax|Zˇ", [8..9, 1..2]), ("\t\u{301}x\n\t\u{301}xˇ", [1..1, 1..1]), ("\t\u{301}x\n\t\u{301}xˇ", [1..3, 1..3]), ] { for above in [false, true] { for reversed in [false, true] { cx.set_state(text); cx.update_editor(|editor, window, cx| { assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); }); } } } } #[gpui::test] async fn test_add_selection_goal_after_moving(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("abˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); editor.move_right(&MoveRight, window, cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcˇdef\nabcˇdef\nabcˇdef"); } #[gpui::test] async fn test_add_selection_unwrapped_unprojectable_sources(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for above in [false, true] { for reversed in [false, true] { for mixed in [false, true] { cx.set_state("aˇ\nb\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { let position = editor .buffer .read(cx) .snapshot(cx) .anchor_before(Point::new(1, 0)); editor.splice_inlays( &[], vec![inlays::Inlay::mock_hint(0, position, "HH")], cx, ); let source = if reversed { Point::new(1, 0)..Point::new(0, 1) } else { Point::new(0, 1)..Point::new(1, 0) }; let other_row = if above { 3 } else { 2 }; let mut original = vec![source.clone()]; if mixed { original.push(Point::new(other_row, 1)..Point::new(other_row, 1)); } editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| selections.select_ranges(original.clone()), ); let original_id = editor.selections.first_anchor().id; add_selection_in_direction(editor, above, true, window, cx); let mut expected = vec![source]; if mixed { expected.extend([ Point::new(2, 1)..Point::new(2, 1), Point::new(3, 1)..Point::new(3, 1), ]); } assert_add_selection_ranges(editor, &expected, cx); assert_eq!(editor.selections.first_anchor().id, original_id); add_selection_in_direction(editor, !above, true, window, cx); assert_add_selection_ranges(editor, &original, cx); assert_eq!(editor.selections.first_anchor().id, original_id); editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); }); } } } } #[gpui::test] async fn test_add_selection_unwrapped_multiline_goal_and_reversal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, text, source, columns) in [ ( false, "abc\ndefghi\nuvwxyz\nabcdefˇ", Point::new(0, 1)..Point::new(1, 4), [1..3, 1..4, 1..4, 1..4], ), ( true, "abcdef\nuvwxyz\ndefghi\nabcˇ", Point::new(2, 4)..Point::new(3, 1), [1..4, 1..4, 1..4, 1..3], ), ] { for reversed in [false, true] { for deferred in [false, true] { cx.set_state(text); cx.update_editor(|editor, window, cx| { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([if reversed { source.end..source.start } else { source.clone() }]); }, ); for (direction, count) in [ (above, 3), (above, 4), (!above, 3), (above, 4), (!above, 3), (!above, 2), (!above, 1), (!above, 1), (above, 2), ] { if deferred { editor.with_selection_effects_deferred( window, cx, |editor, window, cx| { add_selection_in_direction(editor, direction, true, window, cx); }, ); } else { add_selection_in_direction(editor, direction, true, window, cx); } let expected = columns .iter() .enumerate() .filter(|(row, _)| { if above { *row >= 4 - count } else { *row < count } }) .map(|(row, columns)| { let start = Point::new(row as u32, columns.start); let end = Point::new(row as u32, columns.end); if reversed { end..start } else { start..end } }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); } }); } } } } #[gpui::test] async fn test_add_selection_unwrapped_single_projection_retains_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("a«bc\nˇ»\nabcdef"); cx.update_editor(|editor, window, cx| { let position = editor .buffer .read(cx) .snapshot(cx) .anchor_before(Point::new(1, 0)); editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HHHH")], cx); editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); }); }); cx.assert_editor_state("a«bcˇ»\n\nabcdef"); for _ in 0..2 { cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\n\na«bcdˇ»ef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\n\nabcdef"); } } #[gpui::test] async fn test_add_selection_unwrapped_multiline_goal_after_moving_or_typing( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for typing in [false, true] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); if typing { editor.handle_input("X", window, cx); } else { editor.move_right(&MoveRight, window, cx); } add_selection_in_direction(editor, false, true, window, cx); }); let expected = if typing { "aXˇ\ndXˇhi\nuXˇyz\nabˇcdef" } else { "abcˇ\ndefgˇhi\nuvwxˇyz\nabcˇdef" }; cx.assert_editor_state(expected); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state(expected); } } #[gpui::test] fn test_add_selection_unwrapped_reseeds_after_folding_source_buffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«x\nabcdef\nabcdef»"]); let source_buffer_id = cx.multibuffer(|buffer, cx| { buffer .snapshot(cx) .excerpts() .next() .expect("source excerpt") .context .start .buffer_id }); cx.set_selections_state("abcdˇef\nx\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcdˇef\nxˇ\nabcdˇef\nabcdef"); cx.update_editor(|editor, _, cx| { editor.fold_buffer(source_buffer_id, cx); }); cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); } #[gpui::test] fn test_add_selection_unwrapped_retains_goal_after_folding_projection_buffer( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); for above in [false, true] { let mut cx = EditorTestContext::new_multibuffer( cx, if above { ["«abcdef»", "«uvwxyz»", "«defghi\nabc»"] } else { ["«abc\ndefghi»", "«uvwxyz»", "«abcdef»"] }, ); let projection_buffer_id = cx.multibuffer(|buffer, cx| { buffer .snapshot(cx) .excerpts() .nth(1) .expect("projection excerpt") .context .start .buffer_id }); cx.set_selections_state(if above { "abcdef\nuvwxyz\ndefg«hi\naˇ»bc" } else { "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef" }); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(if above { "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»" } else { "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef" }); cx.update_editor(|editor, _, cx| { editor.fold_buffer(projection_buffer_id, cx); }); cx.assert_editor_state(if above { "abcdef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" } else { "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\nabcdef" }); for _ in 0..2 { cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(if above { "a«bcdˇ»ef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" } else { "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\na«bcdˇ»ef" }); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, !above, true, window, cx); }); } } } #[gpui::test] async fn test_add_selection_reverses_groups_with_deleted_sources(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abˇcdef\nabcdef\nabcdef\nabˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); }); cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); cx.update_editor(|editor, window, cx| { let snapshot = editor.display_snapshot(cx); let source_ids = editor .selections .all::(&snapshot) .into_iter() .filter(|selection| selection.start.row % 3 == 0) .map(|selection| selection.id) .collect::>(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { for id in source_ids { selections.delete(id); } }); add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); }); cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); } } #[gpui::test] async fn test_add_selection_after_members_span_rows(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abˇcdef\nabcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); editor.select_down(&SelectDown, window, cx); }); cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); }); cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabˇcdef"); } } #[gpui::test] async fn test_add_selection_fresh_pixel_source_reseeds_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for above in [false, true] { cx.set_state(if above { "abcdef\nx\nabcdˇef" } else { "abcdˇef\nx\nabcdef" }); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, false, window, cx); }); cx.assert_editor_state(if above { "abcdef\nxˇ\nabcdˇef" } else { "abcdˇef\nxˇ\nabcdef" }); cx.update_editor(|editor, window, cx| { let source_row = if above { 2 } else { 0 }; let source_id = editor .selections .disjoint_anchors() .iter() .find(|selection| { selection .start .to_point(&editor.buffer.read(cx).snapshot(cx)) .row == source_row }) .expect("source selection") .id; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.delete(source_id); }); let clamped_id = editor.selections.disjoint_anchors()[0].id; add_selection_in_direction(editor, above, false, window, cx); assert_eq!( stored_selection_ranges(editor, cx), if above { [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), ] } else { [ Point::new(1, 1)..Point::new(1, 1), Point::new(2, 1)..Point::new(2, 1), ] } ); assert!( editor .selections .disjoint_anchors() .iter() .any(|selection| selection.id == clamped_id) ); }); } } #[gpui::test] async fn test_add_selection_keeps_fresh_source_inside_fold(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.fold_creases( vec![Crease::simple( Point::new(0, 0)..Point::new(1, 6), FoldPlaceholder::test(), )], false, window, cx, ); assert_eq!(editor.display_text(cx), "⋯\nabcdef"); let source_id = editor.selections.disjoint_anchors()[0].id; add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); let stored = stored_selection_ranges(editor, cx); assert_eq!(stored[0], Point::new(0, 2)..Point::new(0, 2)); assert_eq!(stored.len(), 2); assert_eq!(stored[1].start.row, 2); assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); assert_eq!( stored_selection_ranges(editor, cx), [Point::new(0, 2)..Point::new(0, 2)] ); assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); }); } } #[gpui::test] async fn test_add_selection_keeps_group_members_coalesced_by_fold(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); editor.fold_creases( vec![Crease::simple( Point::new(0, 0)..Point::new(1, 6), FoldPlaceholder::test(), )], false, window, cx, ); assert_eq!(editor.display_text(cx), "⋯\nabcdef"); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); let stored = stored_selection_ranges(editor, cx); assert_eq!( stored[..2], [ Point::new(0, 2)..Point::new(0, 2), Point::new(1, 2)..Point::new(1, 2), ] ); assert_eq!(stored.len(), 3); assert_eq!(stored[2].start.row, 2); add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); assert_eq!( stored_selection_ranges(editor, cx), [Point::new(0, 2)..Point::new(0, 2)] ); }); } } #[gpui::test] fn test_add_selection_keeps_sources_on_block_rows(cx: &mut TestAppContext) { init_test(cx, |_| {}); for skip_soft_wrap in [false, true] { for visible_source in [None, Some(false), Some(true)] { let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«abcdef\nabcdef»"]); let buffer_ids = cx.multibuffer(|buffer, cx| { buffer .snapshot(cx) .excerpts() .map(|excerpt| excerpt.context.start.buffer_id) .collect::>() }); let folded = if visible_source.is_some() { &buffer_ids[..1] } else { &buffer_ids[..] }; cx.set_selections_state("abˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, _, cx| { editor.fold_buffers(folded.iter().copied(), cx); }); let mut expected = vec![Point::zero()..Point::zero()]; cx.update_editor(|editor, window, cx| { assert_eq!(stored_selection_ranges(editor, cx), expected); if let Some(pending) = visible_source { expected.push(Point::new(1, 2)..Point::new(1, 2)); if pending { let position = Point::new(1, 2).to_display_point(&editor.display_snapshot(cx)); editor.begin_selection(position, true, 1, window, cx); assert!(editor.selections.pending_anchor().is_some()); } else { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(expected.clone()); }, ); } assert_eq!(stored_selection_ranges(editor, cx), expected); } add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); if visible_source.is_some() { expected.push(Point::new(2, 2)..Point::new(2, 2)); } assert_eq!(stored_selection_ranges(editor, cx), expected); add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); if visible_source.is_some() { expected.pop(); } assert_eq!(stored_selection_ranges(editor, cx), expected); editor.handle_input("X", window, cx); }); cx.update_editor(|editor, _, cx| { for buffer_id in folded { editor.unfold_buffer(*buffer_id, cx); } }); cx.assert_editor_state(if visible_source.is_some() { "Xˇabcdef\nabXˇcdef\nabcdef" } else { "Xˇabcdef\nabcdef\nabcdef" }); } } } #[gpui::test] fn test_add_selection_skips_block_rows(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new_multibuffer( cx, ["«abcdef»", "«abcdefghij\nx»", "«abcdef»", "«abcdefghij\nx»"], ); let folded_buffer_ids = cx.multibuffer(|buffer, cx| { buffer .snapshot(cx) .excerpts() .map(|excerpt| excerpt.context.start.buffer_id) .skip(1) .step_by(2) .collect::>() }); for skip_soft_wrap in [false, true] { for column in [0, 4] { let source = Point::new(0, column)..Point::new(0, column); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([source.clone()]); }); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); assert_add_selection_ranges( editor, &[source.clone(), Point::new(1, column)..Point::new(1, column)], cx, ); }); } } cx.update_editor(|editor, _, cx| { editor.fold_buffers(folded_buffer_ids, cx); }); for skip_soft_wrap in [false, true] { for (column, above) in [(0, false), (4, false), (0, true), (4, true)] { let source_row = if above { 3 } else { 0 }; let source = Point::new(source_row, column)..Point::new(source_row, column); let target = Point::new(3 - source_row, column)..Point::new(3 - source_row, column); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([source.clone()]); }); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); let mut expected = vec![source.clone(), target]; expected.sort_by_key(|range| range.start); assert_add_selection_ranges(editor, &expected, cx); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); assert_add_selection_ranges(editor, &expected, cx); add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); assert_add_selection_ranges(editor, std::slice::from_ref(&source), cx); }); } } } #[gpui::test] async fn test_add_selection_history_restores_groups(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, initial, two, three) in [ ( false, "abcdˇef\nab\nabcdef\nabcdef", "abcdˇef\nabˇ\nabcdef\nabcdef", "abcdˇef\nabˇ\nabcdˇef\nabcdef", ), ( true, "abcdef\nabcdef\nab\nabcdˇef", "abcdef\nabcdef\nabˇ\nabcdˇef", "abcdef\nabcdˇef\nabˇ\nabcdˇef", ), ] { for skip_soft_wrap in [false, true] { for deferred in [false, true] { cx.set_state(initial); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); }); cx.assert_editor_state(two); cx.update_editor(|editor, window, cx| { if deferred { editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); }); } else { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); } }); cx.assert_editor_state(three); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state(two); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); }); cx.assert_editor_state(three); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); }); cx.assert_editor_state(initial); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state(two); cx.update_editor(|editor, window, cx| { editor.redo_selection(&RedoSelection, window, cx); }); cx.assert_editor_state(initial); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); }); cx.assert_editor_state(three); } } } } #[gpui::test] async fn test_add_selection_history_deferred_batch(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); }); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.redo_selection(&RedoSelection, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); } } #[gpui::test] async fn test_add_selection_history_deferred_external_movement(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for move_first in [false, true] { cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { if move_first { add_selection_in_direction(editor, false, true, window, cx); } editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { if !move_first { add_selection_in_direction(editor, false, true, window, cx); } editor.move_right(&MoveRight, window, cx); if move_first { add_selection_in_direction(editor, false, true, window, cx); } }); }); if move_first { cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); } else { cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); } } } #[gpui::test] async fn test_add_selection_history_restores_mode_before_switch(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for deferred in [false, true] { cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); if deferred { editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); }); } else { add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); } }); cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.redo_selection(&RedoSelection, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); }); cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); } } } #[gpui::test] async fn test_add_selection_history_restores_unclipped_multiline_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, initial, three, four) in multiline_add_selection_history_states() { cx.set_state(initial); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(four); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state(three); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(four); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); editor.redo_selection(&RedoSelection, window, cx); }); cx.assert_editor_state(four); } } #[gpui::test] async fn test_add_selection_text_undo_restores_multiline_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, initial, three, four) in multiline_add_selection_history_states() { for grouped in [false, true] { cx.set_state(initial); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); editor.transact(window, cx, |editor, window, cx| { editor.insert("X", window, cx); if grouped { editor.insert("Y", window, cx); } }); editor.undo(&Undo, window, cx); }); cx.assert_editor_state(three); cx.update_editor(|editor, window, cx| { editor.redo(&Redo, window, cx); editor.undo(&Undo, window, cx); add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(four); } } } #[gpui::test] async fn test_add_selection_history_preserves_hidden_sources(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for source_count in [1, 2] { cx.set_state("abˇcdef\nabcdef\nabcdef"); cx.update_editor(|editor, window, cx| { let sources = (0..source_count) .map(|row| Point::new(row, 2)..Point::new(row, 2)) .collect::>(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(sources.clone()); }); editor.fold_creases( vec![Crease::simple( Point::new(0, 0)..Point::new(1, 6), FoldPlaceholder::test(), )], false, window, cx, ); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); let mut added = sources.clone(); added.push(Point::new(2, 0)..Point::new(2, 0)); assert_eq!(stored_selection_ranges(editor, cx), added); editor.undo_selection(&UndoSelection, window, cx); assert_eq!(stored_selection_ranges(editor, cx), sources); editor.redo_selection(&RedoSelection, window, cx); assert_eq!(stored_selection_ranges(editor, cx), added); editor.undo_selection(&UndoSelection, window, cx); assert_eq!(stored_selection_ranges(editor, cx), sources); }); } } } #[gpui::test] async fn test_add_selection_removing_projection_retains_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for remove_by_click in [false, true] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); cx.update_editor(|editor, window, cx| { if remove_by_click { editor.begin_selection(DisplayPoint::new(DisplayRow(2), 4), true, 1, window, cx); } else { add_selection_in_direction(editor, true, true, window, cx); } }); cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nuvwxyz"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); } } #[gpui::test] async fn test_add_selection_coalesced_source_reseeds_goal_and_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("«abcdˇ»\nabcˇd\nx\nabcd"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); }); cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); } #[gpui::test] async fn test_add_selection_replacement_discards_dead_groups_and_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("aˇbcd\nabcd\nabcd"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); assert_eq!( editor.add_selections_state.as_ref().map(|state| { state .groups .iter() .map(|group| group.stack.len()) .collect::>() }), Some(vec![3]) ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(0, 1)..Point::new(0, 1)]); }); assert!(editor.add_selections_state.is_none()); editor.move_right(&MoveRight, window, cx); editor.move_left(&MoveLeft, window, cx); assert!(editor.add_selections_state.is_none()); assert_eq!( editor .selection_history .undo_stack .iter() .rev() .take(2) .map(|entry| entry .add_selections_state .as_ref() .map(|state| state.groups.len())) .collect::>(), [None, None] ); editor.undo_selection(&UndoSelection, window, cx); assert!(editor.add_selections_state.is_none()); }); cx.assert_editor_state("abˇcd\nabcd\nabcd"); } } #[gpui::test] async fn test_add_selection_cancel_reseeds_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, initial, cancelled, restarted) in [ ( false, "a«bc\ndefgˇ»hi\nuvwxyz", "a«bcˇ»\ndefghi\nuvwxyz", "a«bcˇ»\nd«efˇ»ghi\nuvwxyz", ), ( true, "uvwxyz\na«bc\ndefgˇ»hi", "uvwxyz\na«bcˇ»\ndefghi", "u«vwˇ»xyz\na«bcˇ»\ndefghi", ), ] { for skip_soft_wrap in [false, true] { cx.set_state(initial); let grouped = cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); let grouped = editor .selections .ranges::(&editor.display_snapshot(cx)); editor.cancel(&Cancel, window, cx); grouped }); cx.assert_editor_state(cancelled); cx.update_editor(|editor, window, cx| { editor.undo_selection(&UndoSelection, window, cx); add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); assert_add_selection_ranges(editor, &grouped, cx); editor.cancel(&Cancel, window, cx); editor.undo_selection(&UndoSelection, window, cx); editor.redo_selection(&RedoSelection, window, cx); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); }); cx.assert_editor_state(restarted); } } } #[gpui::test] async fn test_add_selection_unrelated_pending_retains_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (finish_pending, cancel_pending) in [(false, false), (true, false), (false, true)] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); editor.begin_selection(DisplayPoint::new(DisplayRow(4), 0), true, 1, window, cx); if finish_pending { editor.end_selection(window, cx); } else if cancel_pending { editor.cancel(&Cancel, window, cx); } add_selection_in_direction(editor, false, true, window, cx); }); let caret = if cancel_pending { "" } else { "ˇ" }; cx.assert_editor_state(&format!( "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef\n{caret}012345" )); } } #[gpui::test] async fn test_add_selection_pending_overlap_cancel_reverses_group(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for above in [false, true] { for overlap_source in [false, true] { for restore_history in [false, true] { cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); cx.update_editor(|editor, window, cx| { let source_row = if above { 2 } else { 1 }; let added_row = if above { 1 } else { 2 }; let source = Point::new(source_row, 2); editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([source..source]); }, ); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); begin_add_selection_overlap( editor, if overlap_source { source_row } else { added_row }, 0..3, window, cx, ); editor.cancel(&Cancel, window, cx); if restore_history { editor.undo_selection(&UndoSelection, window, cx); editor.redo_selection(&RedoSelection, window, cx); } assert_add_selection_ranges( editor, &[ Point::new(1, 2)..Point::new(1, 2), Point::new(2, 2)..Point::new(2, 2), ], cx, ); add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); assert_add_selection_ranges(editor, &[source..source], cx); editor.undo_selection(&UndoSelection, window, cx); editor.redo_selection(&RedoSelection, window, cx); assert_add_selection_ranges(editor, &[source..source], cx); }); } } } } } #[gpui::test] async fn test_add_selection_pending_overlap_commit_or_consume(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for finish_pending in [false, true] { for (above, overlap_row, columns) in [ (false, 1, [(0, 0..3), (1, 0..3), (2, 2..2)]), (false, 2, [(0, 2..2), (1, 0..3), (2, 0..3)]), (true, 1, [(1, 0..3), (2, 0..3), (3, 2..2)]), (true, 2, [(1, 2..2), (2, 0..3), (3, 0..3)]), ] { cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); cx.update_editor(|editor, window, cx| { let source = Point::new(if above { 2 } else { 1 }, 2); editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([source..source]); }, ); add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); begin_add_selection_overlap(editor, overlap_row, 0..3, window, cx); if finish_pending { editor.end_selection(window, cx); } add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); let expected = columns .into_iter() .map(|(row, columns)| { Point::new(row, columns.start)..Point::new(row, columns.end) }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); assert_eq!(editor.selections.pending_anchor(), None); }); } } } } #[gpui::test] async fn test_add_selection_pending_overlap_multiline_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for (above, initial, _, _) in multiline_add_selection_history_states() { for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { cx.set_state(initial); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); begin_add_selection_overlap( editor, if above { 2 } else { 1 }, 0..2, window, cx, ); if cancel_pending { editor.cancel(&Cancel, window, cx); editor.undo_selection(&UndoSelection, window, cx); editor.redo_selection(&RedoSelection, window, cx); } else if finish_pending { editor.end_selection(window, cx); } add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); let columns = if cancel_pending { [1..3, 1..4, 1..4, 1..4] } else { [1..3, 0..4, 0..4, 1..if skip_soft_wrap { 3 } else { 4 }] }; let expected = (0..4) .map(|row| { let columns = &columns[if above { 3 - row } else { row }]; Point::new(row as u32, columns.start) ..Point::new(row as u32, columns.end) }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); }); } } } } #[gpui::test] async fn test_add_selection_pending_overlap_committed_source_changes(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (above, initial, _, _) in multiline_add_selection_history_states() { cx.set_state(initial); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); begin_add_selection_overlap(editor, if above { 2 } else { 1 }, 0..2, window, cx); let snapshot = editor.display_snapshot(cx); let source_row = if above { 3 } else { 0 }; let selections = editor .selections .disjoint_anchors() .iter() .map(|selection| { let mut selection = selection.map(|anchor| anchor.to_point(snapshot.buffer_snapshot())); if selection.start.row == source_row { selection.end.column = 2; } selection }) .collect::>(); let pending = *editor.selections.pending_anchor().expect("pending overlap"); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |collection| { collection.select(selections); collection.set_pending(pending, SelectMode::Character); }); editor.cancel(&Cancel, window, cx); add_selection_in_direction(editor, above, true, window, cx); let columns = [1..2, 1..4, 1..4, 1..2]; let expected = (0..4) .map(|row| { let columns = &columns[if above { 3 - row } else { row }]; Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); }); } } #[gpui::test] async fn test_add_selection_pending_overlap_retreat_after_buffer_edit(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for (above, initial, _, _) in multiline_add_selection_history_states() { for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { cx.set_state(initial); let overlap_row = if above { 2 } else { 1 }; cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); begin_add_selection_overlap(editor, overlap_row, 0..2, window, cx); }); cx.update_buffer(|buffer, cx| { let start = Point::new(overlap_row, 0); buffer.edit([(start..start, "ZZ")], None, cx); }); cx.update_editor(|editor, window, cx| { editor.update_selection( DisplayPoint::new(DisplayRow(overlap_row), 0), 0, gpui::Point::::default(), window, cx, ); if cancel_pending { editor.cancel(&Cancel, window, cx); } else if finish_pending { editor.end_selection(window, cx); } add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); let columns = [1..3, 3..6, 1..4, 1..if skip_soft_wrap { 6 } else { 4 }]; let mut expected = (0..4) .map(|row| { let columns = &columns[if above { 3 - row } else { row }]; Point::new(row as u32, columns.start) ..Point::new(row as u32, columns.end) }) .collect::>(); if !cancel_pending { expected.extend((1..3).map(|row| Point::new(row, 0)..Point::new(row, 0))); expected.sort_by_key(|range| range.start); } assert_add_selection_ranges(editor, &expected, cx); }); } } } } #[gpui::test] async fn test_add_selection_pending_overlap_absorbs_multiline_source(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); editor.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(1), 4), 0, gpui::Point::::default(), window, cx, ); if cancel_pending { editor.cancel(&Cancel, window, cx); } else if finish_pending { editor.end_selection(window, cx); } add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); let columns = if cancel_pending { [1..3, 1..4, 1..2, 1..4] } else { [0..3, 0..4, 0..2, 1..2] }; let expected = columns .into_iter() .enumerate() .map(|(row, columns)| { Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); }); } } } #[gpui::test] async fn test_add_selection_goal_changes_are_group_local(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for remote in [false, true] { for (changed_row, columns, goal_end) in [ (0, Some(1..2), 2), (1, Some(1..2), 3), (0, None, 4), (1, None, 4), (1, Some(1..4), 4), ] { cx.set_state( "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n\na«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345", ); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.insert_range(Point::new(9, 0)..Point::new(9, 0)); }); let snapshot = editor.display_snapshot(cx); let selections = editor .selections .all_unexpanded::(&snapshot) .into_iter() .filter_map(|mut selection| { if selection.start.row == changed_row { let columns = columns.as_ref()?; selection.start.column = columns.start; selection.end.column = columns.end; } Some(selection) }) .collect::>(); if remote { let selections = selections .into_iter() .map(|selection| { selection.map(|point| snapshot.buffer_snapshot().anchor_before(point)) }) .collect(); editor.set_selections_from_remote(selections, None, window, cx); } else { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |collection| { collection.select(selections); }, ); } add_selection_in_direction(editor, false, true, window, cx); let expected = (0..10) .filter_map(|row| { let columns = if row == changed_row { columns.clone()? } else { match row { 0 | 5 => 1..3, 1 | 2 | 6 | 7 | 8 => 1..4, 3 => 1..goal_end, 9 => 0..0, _ => return None, } }; Some(Point::new(row, columns.start)..Point::new(row, columns.end)) }) .collect::>(); assert_add_selection_ranges(editor, &expected, cx); }); } } } #[gpui::test] async fn test_add_selection_overlap_invalidates_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (pending, finish_pending) in [(false, false), (true, false), (true, true)] { for preceding_range in [false, true] { for (columns, pending_goal, inserted_goal) in [(0..1, 4, 4), (0..2, 3, 3), (4..4, 3, 4), (4..5, 4, 4)] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); if preceding_range { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.insert_range(Point::new(1, 0)..Point::new(1, 1)); }); } let snapshot = editor.display_snapshot(cx); let range = snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.start)) ..snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.end)); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { if pending { selections.set_pending_anchor_range(range, SelectMode::Character); } else { selections.insert_range(range); } }); if finish_pending { editor.end_selection(window, cx); } add_selection_in_direction(editor, false, true, window, cx); let goal_end = if pending { pending_goal } else { inserted_goal }; assert_eq!( editor.selections.ranges::(&editor.display_snapshot(cx)) .into_iter() .filter(|range| range.start.row == 3) .collect::>(), [Point::new(3, 1)..Point::new(3, goal_end)], "pending={pending}, finish_pending={finish_pending}, preceding_range={preceding_range}, columns={columns:?}", ); }); } } } } #[gpui::test] async fn test_add_selection_sync_preserves_unchanged_goals(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for subscribe_before in [false, true] { for move_source in [false, true] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); let (_source, _subscription) = cx.update_editor(|editor, window, cx| { let source = cx.new(|cx| editor.clone(window, cx)); let subscription = subscribe_before.then(|| editor.sync_selections(source.clone(), cx)); source.update(cx, |source, cx| { source.change_selections( SelectionEffects::no_scroll(), window, cx, |collection| { let mut selections = collection.all_unexpanded::( &collection.display_snapshot(), ); for selection in &mut selections { if move_source && selection.start == MultiBufferOffset(1) { selection.end = MultiBufferOffset(2); } } collection.select(selections); }, ); }); let subscription = subscription.unwrap_or_else(|| editor.sync_selections(source.clone(), cx)); (source, subscription) }); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state(if move_source { "a«bˇ»c\nd«efgˇ»hi\nu«vwxˇ»yz\na«bˇ»cdef" } else { "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef" }); } } } #[gpui::test] async fn test_add_selection_unwrapped_goal_reseeds_from_oldest_survivor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); let oldest_id = editor.selections.oldest_anchor().id; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.delete(oldest_id); }); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("abc\nd«efgˇ»hi\nu«vˇ»\na«bcdˇ»ef"); } #[gpui::test] async fn test_add_selection_unwrapped_multiline_goal_follows_buffer_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for add_pending in [false, true] { cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdefgh"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.update_buffer(|buffer, cx| { buffer.edit([(Point::new(1, 0)..Point::new(1, 0), "ZZ")], None, cx); }); cx.update_editor(|editor, window, cx| { if add_pending { editor.begin_selection(DisplayPoint::new(DisplayRow(3), 0), true, 1, window, cx); editor.end_selection(window, cx); } add_selection_in_direction(editor, false, true, window, cx); }); let caret = if add_pending { "ˇ" } else { "" }; cx.assert_editor_state(&format!( "a«bcˇ»\nZZd«efgˇ»hi\nu«vwxˇ»yz\n{caret}a«bcdefˇ»gh" )); } } #[gpui::test] async fn test_add_selection_unwrapped_multiline_goal_follows_tab_size(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = NonZeroU32::new(4); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state("a«bc\n\tˇ»defgh\nabcdefghij\nabcdefghij"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\nabcdefghij"); for (tab_size, expected) in [ (8, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdefghˇ»ij"), (4, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdˇ»efghij"), ] { update_test_language_settings(&mut cx, &|settings| { settings.defaults.tab_size = NonZeroU32::new(tab_size); }); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state(expected); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); }); } } #[gpui::test] async fn test_add_selection_unwrapped_multiline_goal_follows_inlays(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("a«bc\ndefgˇ»hi\nabcdefghij\nabcdefghij"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); let position = editor .buffer .read(cx) .snapshot(cx) .anchor_before(Point::new(1, 0)); editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HH")], cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdefˇ»ghij"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdˇ»efghij"); } #[gpui::test] async fn test_add_selection_unwrapped_clears_pixel_goals(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("abˇcdef\nabcdef"); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.move_with(&mut |_, selection| { selection.goal = SelectionGoal::HorizontalRange { start: 0.0, end: 200.0, }; }); }); add_selection_in_direction(editor, false, true, window, cx); assert_eq!( editor .selections .all::(&editor.display_snapshot(cx)) .iter() .map(|selection| selection.goal) .collect::>(), [SelectionGoal::None, SelectionGoal::None], ); }); cx.assert_editor_state("abˇcdef\nabˇcdef"); } #[gpui::test] async fn test_add_selection_skips_inlay_inside_grapheme(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (prefix, row) in [ (String::new(), 0), (format!("{}\n\n\n", "a".repeat(121)), 3), ] { cx.set_state(&format!("{prefix}👩\u{200d}💻|Z\nˇ")); cx.update_editor(|editor, window, cx| { let buffer = editor.buffer.read(cx).snapshot(cx); let position = buffer.anchor_before(Point::new(row, 4)); editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "\n")], cx); add_selection_in_direction(editor, true, true, window, cx); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [ Point::new(row, 0)..Point::new(row, 0), Point::new(row + 1, 0)..Point::new(row + 1, 0) ] ); }); } } #[gpui::test] async fn test_add_selection_unwrapped_en_dash(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for columns in [[4..4, 2..2], [3..4, 1..2], [4..6, 2..4]] { for above in [false, true] { for reversed in [false, true] { cx.set_state("–x|Z\nab|Zˇ"); cx.update_editor(|editor, window, cx| { assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); }); } } } } #[gpui::test] async fn test_add_selection_unwrapped_unicode_tabs(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.hard_tabs = Some(true); settings.defaults.tab_size = Some(4.try_into().expect("nonzero tab size")); }); let mut cx = EditorTestContext::new(cx).await; for columns in [[8..8, 4..4], [6..8, 3..4], [8..10, 4..6]] { for above in [false, true] { for reversed in [false, true] { cx.set_state("–é\tλ|Z\nab\tc|Zˇ"); cx.update_editor(|editor, window, cx| { assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); }); } } } } #[gpui::test] async fn test_add_selection_unwrapped_source_range_across_wraps(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for wrap_width in [None, Some(px(60.0)), Some(px(90.0))] { for columns in [3..18, 3..20, 18..18] { for above in [false, true] { for reversed in [false, true] { cx.set_state(" abcdefghijklmnop|Z\n ab cd efghijklmnop|Zˇ"); cx.update_editor(|editor, window, cx| { editor.set_wrap_width(wrap_width, cx); if wrap_width.is_some() { let snapshot = editor.display_snapshot(cx); for row in 0..2 { assert_ne!( Point::new(row, 3).to_display_point(&snapshot).row(), Point::new(row, 18).to_display_point(&snapshot).row(), ); } } assert_add_selection_pair( editor, [columns.clone(), columns.clone()], above, reversed, window, cx, ); }); } } } } } #[gpui::test] async fn test_add_selection_unwrapped_clamp_retains_goal(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (before, intermediate, after, above) in [ ( "–abcˇ|Z\nx\nwxyz|Z", "–abcˇ|Z\nxˇ\nwxyz|Z", "–abcˇ|Z\nxˇ\nwxyzˇ|Z", false, ), ( "wxyz|Z\nx\n–abcˇ|Z", "wxyz|Z\nxˇ\n–abcˇ|Z", "wxyzˇ|Z\nxˇ\n–abcˇ|Z", true, ), ( "–a«ˇbc|Z»\nxyz\nuvwxyz", "–a«ˇbc|Z»\nxy«ˇz»\nuvwxyz", "–a«ˇbc|Z»\nxy«ˇz»\nuv«ˇwxyz»", false, ), ( "uvwxyz\nxyz\n–a«bc|Zˇ»", "uvwxyz\nxy«zˇ»\n–a«bc|Zˇ»", "uv«wxyzˇ»\nxy«zˇ»\n–a«bc|Zˇ»", true, ), ] { cx.set_state(before); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(intermediate); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(after); } } #[gpui::test] async fn test_add_selection_unwrapped_multiple_groups(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("–aˇbcdef\nx\nabcdef\nabc«ˇde»f\nxy\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, false, true, window, cx); }); cx.assert_editor_state("–aˇbcdef\nxˇ\nabˇcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); }); cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabcdef"); cx.update_editor(|editor, window, cx| { add_selection_in_direction(editor, true, true, window, cx); }); cx.assert_editor_state("–aˇbcdef\nx\nabc«ˇde»f\nabc«ˇde»f\nxy\nabcdef"); } #[gpui::test] async fn test_add_selection_unwrapped_mode_switch_resets_groups(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for skip_soft_wrap in [false, true] { cx.set_state("abˇcdefghijklmnop\nqrstuvwxyzABCDEF\n0123456789abcdef"); cx.update_editor(|editor, window, cx| { editor.set_wrap_width(None, cx); let details = editor.text_layout_details(window, cx); let width = editor .display_snapshot(cx) .layout_row(DisplayRow(0), &details) .width / 2.0; editor.set_wrap_width(Some(width), cx); assert_eq!( editor.display_text(cx), "abcdefgh\nijklmnop\nqrstuvwx\nyzABCDEF\n01234567\n89abcdef", ); add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); add_selection_in_direction(editor, skip_soft_wrap, !skip_soft_wrap, window, cx); }); if skip_soft_wrap { cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzABCDEF\n0123456789abcdef"); } else { cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzˇABCDEF\n0123456789abcdef"); } } } #[gpui::test] async fn test_add_selection_unwrapped_eof_and_skipped_range_terminate(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for state in [ "abcdefghijklmnopˇ", "abcdefghijklmnop\nqrstuvwxyzABCDEFˇ", "abcdefghijkl«mnopˇ»\nabcdefgh", ] { cx.set_state(state); cx.update_editor(|editor, window, cx| { editor.set_wrap_width(None, cx); let details = editor.text_layout_details(window, cx); let width = editor .display_snapshot(cx) .layout_row(DisplayRow(0), &details) .width / 4.0; editor.set_wrap_width(Some(width), cx); assert!(editor.display_snapshot(cx).max_point().row().0 >= 3); for _ in 0..2 { add_selection_in_direction(editor, false, true, window, cx); } }); cx.assert_editor_state(state); } } #[gpui::test] async fn test_add_selection_unwrapped_inlay_rows_and_widths(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for columns in [[1..1, 3..3], [1..3, 3..5]] { for above in [false, true] { cx.set_state("a|Z\nabc|Zˇ"); cx.update_editor(|editor, window, cx| { let position = editor .buffer .read(cx) .snapshot(cx) .anchor_before(Point::new(0, 0)); editor.splice_inlays( &[], vec![inlays::Inlay::mock_hint(0, position, "hint\n–é")], cx, ); assert_eq!(editor.display_text(cx), "hint\n–éa|Z\nabc|Z"); assert_add_selection_pair(editor, columns.clone(), above, false, window, cx); editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); }); } } } #[gpui::test] async fn test_add_selection_unwrapped_folded_target_boundaries(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for (source, target) in [(4..4, 2..2), (5..5, 8..8), (4..5, 2..8)] { for above in [false, true] { for reversed in [false, true] { cx.set_state(if above { "abhidden|Z\na–|Zˇ" } else { "a–|Z\nabhidden|Zˇ" }); cx.update_editor(|editor, window, cx| { let target_row = u32::from(!above); editor.fold_creases( vec![Crease::simple( Point::new(target_row, 2)..Point::new(target_row, 8), FoldPlaceholder::test(), )], true, window, cx, ); assert_eq!( editor.display_text(cx), if above { "ab⋯|Z\na–|Z" } else { "a–|Z\nab⋯|Z" } ); let columns = if above { [target.clone(), source.clone()] } else { [source.clone(), target.clone()] }; assert_add_selection_pair(editor, columns, above, reversed, window, cx); }); } } } } #[gpui::test] async fn test_add_selection_unwrapped_multiline_fold_rows(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; for above in [false, true] { cx.set_state(if above { "abhidden\nhidden\n|Z\na–ˇ|Z" } else { "a–ˇ|Z\nabhidden\nhidden\n|Z" }); cx.update_editor(|editor, window, cx| { let target_row = u32::from(!above); editor.fold_creases( vec![Crease::simple( Point::new(target_row, 2)..Point::new(target_row + 2, 0), FoldPlaceholder::test(), )], true, window, cx, ); assert_eq!( editor.display_text(cx), if above { "ab⋯|Z\na–|Z" } else { "a–|Z\nab⋯|Z" } ); add_selection_in_direction(editor, above, true, window, cx); }); cx.assert_editor_state(if above { "abˇhidden\nhidden\n|Z\na–ˇ|Z" } else { "a–ˇ|Z\nabˇhidden\nhidden\n|Z" }); } } /// A different number of tabs can align the same column on each row, so a cursor has to /// be placed by the column the tabs expand to. Counting a tab as a single column lands it /// wherever that many characters happen to reach on the next row. #[gpui::test] async fn test_add_selection_below_with_tab_aligned_columns(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.hard_tabs = Some(true); settings.defaults.tab_size = Some(4.try_into().unwrap()); }); let mut cx = EditorTestContext::new(cx).await; // Both `=` render at column 48: `current.rgb.r` ends at 37 and is padded by three // tabs, `residuals[run].rgb.r` ends at 44 and is padded by one. cx.set_state( "\t\t\t\t\t\tcurrent.rgb.r\t\t\tˇ= p[0] >> 8;\n\t\t\t\t\t\tresiduals[run].rgb.r\t= p[0] & 0xff;\n", ); cx.update_editor(|editor, window, cx| { editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state( "\t\t\t\t\t\tcurrent.rgb.r\t\t\tˇ= p[0] >> 8;\n\t\t\t\t\t\tresiduals[run].rgb.r\tˇ= p[0] & 0xff;\n", ); } #[gpui::test] /// Regression test for a panic ("display point out of range"): with a multi-line fold, /// buffer rows below the fold exceed the fold map's max row, so they must be converted /// to tab map rows instead of being used directly. async fn test_add_selection_above_below_with_fold(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc!( r#"fn foo() { aaaa bbbb } one twoˇ"# )); cx.update_editor(|editor, window, cx| { editor.fold_creases( vec![Crease::simple( Point::new(0, 10)..Point::new(3, 0), FoldPlaceholder::test(), )], true, window, cx, ); }); cx.update_editor(|editor, window, cx| { editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc!( r#"fn foo() { aaaa bbbb } oneˇ twoˇ"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc!( r#"fn ˇfoo() { aaaa bbbb } oneˇ twoˇ"# )); cx.set_state(indoc!( r#"fn fˇoo() { aaaa bbbb } one two"# )); cx.update_editor(|editor, window, cx| { editor.fold_creases( vec![Crease::simple( Point::new(0, 10)..Point::new(3, 0), FoldPlaceholder::test(), )], true, window, cx, ); }); cx.update_editor(|editor, window, cx| { editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc!( r#"fn fˇoo() { aaaa bbbb } oneˇ two"# )); } #[gpui::test] async fn test_add_selection_above_below(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc!( r#"abc defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abcˇ defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abcˇ defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.undo_selection(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abcˇ defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.redo_selection(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi ˇ jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi ˇ jkˇ nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi ˇ jkˇ nlmˇo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc defˇghi ˇ jkˇ nlmˇo ˇ"# )); // change selections cx.set_state(indoc!( r#"abc def«ˇg»hi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc def«ˇg»hi jk nlm«ˇo» "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc def«ˇg»hi jk nlm«ˇo» "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc def«ˇg»hi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc def«ˇg»hi jk nlmo "# )); // Change selections again cx.set_state(indoc!( r#"a«bc defgˇ»hi jk nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"a«bcˇ» d«efgˇ»hi j«kˇ» nlmo "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"a«bcˇ» d«efgˇ»hi j«kˇ» n«lmoˇ» "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"a«bcˇ» d«efgˇ»hi j«kˇ» nlmo "# )); // Change selections again cx.set_state(indoc!( r#"abc d«ˇefghi jk nlm»o "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"a«ˇbc» d«ˇef»ghi j«ˇk» n«ˇlm»o "# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"abc d«ˇef»ghi j«ˇk» n«ˇlm»o "# )); // Assert that the oldest selection's goal column is used when adding more // selections, not the most recently added selection's actual column. cx.set_state(indoc! {" foo bar bazˇ foo foo bar "}); cx.update_editor(|editor, window, cx| { editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc! {" foo bar bazˇ fooˇ foo bar "}); cx.update_editor(|editor, window, cx| { editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc! {" foo bar bazˇ fooˇ foo barˇ "}); cx.set_state(indoc! {" foo bar baz foo foo barˇ "}); cx.update_editor(|editor, window, cx| { editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc! {" foo bar baz fooˇ foo barˇ "}); cx.update_editor(|editor, window, cx| { editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: true, }, window, cx, ); }); cx.assert_editor_state(indoc! {" foo barˇ baz fooˇ foo barˇ "}); } #[gpui::test] async fn test_add_selection_above_below_multi_cursor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc!( r#"line onˇe liˇne two line three line four"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // test multiple cursors expand in the same direction cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo liˇne three line four"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // test multiple cursors expand below overflow cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo liˇne thˇree liˇne foˇur"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test multiple cursors retrieves back correctly cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo liˇne thˇree line four"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test multiple cursor groups maintain independent direction - first expands up, second shrinks above cx.assert_editor_state(indoc!( r#"liˇne onˇe liˇne two line three line four"# )); cx.update_editor(|editor, window, cx| { editor.undo_selection(&Default::default(), window, cx); }); // test undo cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo line three line four"# )); cx.update_editor(|editor, window, cx| { editor.redo_selection(&Default::default(), window, cx); }); // test redo cx.assert_editor_state(indoc!( r#"liˇne onˇe liˇne two line three line four"# )); cx.set_state(indoc!( r#"abcd ef«ghˇ» ijkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test multiple selections expand in the same direction cx.assert_editor_state(indoc!( r#"ab«cdˇ» ef«ghˇ» «iˇ»jkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test multiple selection upward overflow cx.assert_editor_state(indoc!( r#"ab«cdˇ» «eˇ»f«ghˇ» «iˇ»jkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // test multiple selection retrieves back correctly cx.assert_editor_state(indoc!( r#"abcd ef«ghˇ» «iˇ»jkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // test multiple cursor groups maintain independent direction - first shrinks down, second expands below cx.assert_editor_state(indoc!( r#"abcd ef«ghˇ» ij«klˇ» «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.undo_selection(&Default::default(), window, cx); }); // test undo cx.assert_editor_state(indoc!( r#"abcd ef«ghˇ» «iˇ»jkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.redo_selection(&Default::default(), window, cx); }); // test redo cx.assert_editor_state(indoc!( r#"abcd ef«ghˇ» ij«klˇ» «mˇ»nop"# )); } #[gpui::test] async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc!( r#"line onˇe liˇne two line three line four"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); editor.add_selection_below(&Default::default(), window, cx); editor.add_selection_below(&Default::default(), window, cx); }); // initial state with two multi cursor groups cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo liˇne thˇree liˇne foˇur"# )); // add single cursor in middle - simulate opt click cx.update_editor(|editor, window, cx| { let new_cursor_point = DisplayPoint::new(DisplayRow(2), 4); editor.begin_selection(new_cursor_point, true, 1, window, cx); editor.end_selection(window, cx); }); cx.assert_editor_state(indoc!( r#"line onˇe liˇne twˇo liˇneˇ thˇree liˇne foˇur"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test new added selection expands above and existing selection shrinks cx.assert_editor_state(indoc!( r#"line onˇe liˇneˇ twˇo liˇneˇ thˇree line four"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); }); // test new added selection expands above and existing selection shrinks cx.assert_editor_state(indoc!( r#"lineˇ onˇe liˇneˇ twˇo lineˇ three line four"# )); // intial state with two selection groups cx.set_state(indoc!( r#"abcd ef«ghˇ» ijkl «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_above(&Default::default(), window, cx); editor.add_selection_above(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"ab«cdˇ» «eˇ»f«ghˇ» «iˇ»jkl «mˇ»nop"# )); // add single selection in middle - simulate opt drag cx.update_editor(|editor, window, cx| { let new_cursor_point = DisplayPoint::new(DisplayRow(2), 3); editor.begin_selection(new_cursor_point, true, 1, window, cx); editor.update_selection( DisplayPoint::new(DisplayRow(2), 4), 0, gpui::Point::::default(), window, cx, ); editor.end_selection(window, cx); }); cx.assert_editor_state(indoc!( r#"ab«cdˇ» «eˇ»f«ghˇ» «iˇ»jk«lˇ» «mˇ»nop"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // test new added selection expands below, others shrinks from above cx.assert_editor_state(indoc!( r#"abcd ef«ghˇ» «iˇ»jk«lˇ» «mˇ»no«pˇ»"# )); } #[gpui::test] async fn test_add_selection_above_below_multibyte(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Cursor after "Häl" (byte column 4, char column 3) should align to // char column 3 on the ASCII line below, not byte column 4. cx.set_state(indoc!( r#"Hälˇlö Hallo"# )); cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( r#"Hälˇlö Halˇlo"# )); } #[gpui::test] async fn test_select_next(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Enable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(true); settings.search = Some(search_settings); }); cx.set_state("abc\nˇabc abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); // Test selection direction should be preserved cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«ˇabc» «ˇabc»\ndefabc\nabc"); // Test case sensitivity cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); cx.update_editor(|e, window, cx| { e.select_next(&SelectNext::default(), window, cx).unwrap(); }); cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); // Disable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(false); settings.search = Some(search_settings); }); cx.set_state("«ˇfoo»\nFOO\nFoo"); cx.update_editor(|e, window, cx| { e.select_next(&SelectNext::default(), window, cx).unwrap(); e.select_next(&SelectNext::default(), window, cx).unwrap(); }); cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); // Enable whole word update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.whole_word = Some(true); settings.search = Some(search_settings); }); cx.set_state("abc\nabc «abcˇ»\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\nabc «abcˇ»\ndefabc\n«abcˇ»"); } #[gpui::test] async fn test_select_all_matches(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Enable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(true); settings.search = Some(search_settings); }); // Test caret-only selections cx.set_state("abc\nˇabc abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); // Test left-to-right selections cx.set_state("abc\n«abcˇ»\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ»\n«abcˇ»"); // Test right-to-left selections cx.set_state("abc\n«ˇabc»\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("«ˇabc»\n«ˇabc»\n«ˇabc»"); // Test selecting whitespace with caret selection cx.set_state("abc\nˇ abc\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("abc\n« ˇ»abc\nabc"); // Test selecting whitespace with left-to-right selection cx.set_state("abc\n«ˇ »abc\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("abc\n«ˇ »abc\nabc"); // Test no matches with right-to-left selection cx.set_state("abc\n« ˇ»abc\nabc"); cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state("abc\n« ˇ»abc\nabc"); // Test with a single word and clip_at_line_ends=true (#29823) cx.set_state("aˇbc"); cx.update_editor(|e, window, cx| { e.set_clip_at_line_ends(true, cx); e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); e.set_clip_at_line_ends(false, cx); }); cx.assert_editor_state("«abcˇ»"); // Test case sensitivity cx.set_state("fˇoo\nFOO\nFoo"); cx.update_editor(|e, window, cx| { e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); }); cx.assert_editor_state("«fooˇ»\nFOO\nFoo"); // Disable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(false); settings.search = Some(search_settings); }); cx.set_state("fˇoo\nFOO\nFoo"); cx.update_editor(|e, window, cx| { e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); }); cx.assert_editor_state("«fooˇ»\n«FOOˇ»\n«Fooˇ»"); } #[gpui::test] async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let large_body_1 = "\nd".repeat(200); let large_body_2 = "\ne".repeat(200); cx.set_state(&format!( "abc\nabc{large_body_1} «ˇa»bc{large_body_2}\nefabc\nabc" )); let initial_scroll_position = cx.update_editor(|editor, _, cx| { let scroll_position = editor.scroll_position(cx); assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); scroll_position }); cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state(&format!( "«ˇa»bc\n«ˇa»bc{large_body_1} «ˇa»bc{large_body_2}\nef«ˇa»bc\n«ˇa»bc" )); cx.update_editor(|editor, _, cx| { assert_eq!( editor.scroll_position(cx), initial_scroll_position, "Scroll position should not change after selecting all matches" ) }); // Simulate typing while the selections are active, as that is where the // editor would attempt to actually scroll to the newest selection, which // should have been set as the original selection to avoid scrolling to the // last match. cx.simulate_keystroke("x"); cx.update_editor(|editor, _, cx| { assert_eq!( editor.scroll_position(cx), initial_scroll_position, "Scroll position should not change after editing all matches" ) }); cx.set_state(&format!( "abc\nabc{large_body_1} «aˇ»bc{large_body_2}\nefabc\nabc" )); let initial_scroll_position = cx.update_editor(|editor, _, cx| { let scroll_position = editor.scroll_position(cx); assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); scroll_position }); cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state(&format!( "«aˇ»bc\n«aˇ»bc{large_body_1} «aˇ»bc{large_body_2}\nef«aˇ»bc\n«aˇ»bc" )); cx.update_editor(|editor, _, cx| { assert_eq!( editor.scroll_position(cx), initial_scroll_position, "Scroll position should not change after selecting all matches" ) }); cx.simulate_keystroke("x"); cx.update_editor(|editor, _, cx| { assert_eq!( editor.scroll_position(cx), initial_scroll_position, "Scroll position should not change after editing all matches" ) }); } #[gpui::test] async fn test_undo_format_scrolls_to_last_edit_pos(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" line 1 line 2 linˇe 3 line 4 line 5 "}); // Make an edit cx.update_editor(|editor, window, cx| { editor.handle_input("X", window, cx); }); // Move cursor to a different position cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(4, 2)..Point::new(4, 2)]); }); }); cx.assert_editor_state(indoc! {" line 1 line 2 linXe 3 line 4 liˇne 5 "}); cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "PREFIX ".to_string(), )])) }); cx.update_editor(|editor, window, cx| editor.format(&Default::default(), window, cx)) .unwrap() .await .unwrap(); cx.assert_editor_state(indoc! {" PREFIX line 1 line 2 linXe 3 line 4 liˇne 5 "}); // Undo formatting cx.update_editor(|editor, window, cx| { editor.undo(&Default::default(), window, cx); }); // Verify cursor moved back to position after edit cx.assert_editor_state(indoc! {" line 1 line 2 linXˇe 3 line 4 line 5 "}); } #[gpui::test] async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let provider = cx.new(|_| FakeEditPredictionDelegate::default()); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider( Some(provider.clone()), EditPredictionRequestTrigger::EditorCreated, window, cx, ); }); cx.set_state(indoc! {" line 1 line 2 linˇe 3 line 4 line 5 line 6 line 7 line 8 line 9 line 10 "}); let snapshot = cx.buffer_snapshot(); let edit_position = snapshot.anchor_after(Point::new(2, 4)); cx.update(|_, cx| { provider.update(cx, |provider, _| { provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { id: None, edits: vec![(edit_position..edit_position, "X".into())], cursor_position: None, edit_preview: None, })) }) }); cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); cx.update_editor(|editor, window, cx| { editor.accept_edit_prediction(&crate::AcceptEditPrediction, window, cx) }); cx.assert_editor_state(indoc! {" line 1 line 2 lineXˇ 3 line 4 line 5 line 6 line 7 line 8 line 9 line 10 "}); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(9, 2)..Point::new(9, 2)]); }); }); cx.assert_editor_state(indoc! {" line 1 line 2 lineX 3 line 4 line 5 line 6 line 7 line 8 line 9 liˇne 10 "}); cx.update_editor(|editor, window, cx| { editor.undo(&Default::default(), window, cx); }); cx.assert_editor_state(indoc! {" line 1 line 2 lineˇ 3 line 4 line 5 line 6 line 7 line 8 line 9 line 10 "}); } #[gpui::test] async fn test_select_next_with_multiple_carets(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( r#"let foo = 2; lˇet foo = 2; let fooˇ = 2; let foo = 2; let foo = ˇ2;"#, ); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state( r#"let foo = 2; «letˇ» foo = 2; let «fooˇ» = 2; let foo = 2; let foo = «2ˇ»;"#, ); // noop for multiple selections with different contents cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state( r#"let foo = 2; «letˇ» foo = 2; let «fooˇ» = 2; let foo = 2; let foo = «2ˇ»;"#, ); // Test last selection direction should be preserved cx.set_state( r#"let foo = 2; let foo = 2; let «fooˇ» = 2; let «ˇfoo» = 2; let foo = 2;"#, ); cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) .unwrap(); cx.assert_editor_state( r#"let foo = 2; let foo = 2; let «fooˇ» = 2; let «ˇfoo» = 2; let «ˇfoo» = 2;"#, ); } #[gpui::test] async fn test_select_previous_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new_multibuffer(cx, ["aaa\n«bbb\nccc»\nddd", "aaa\n«bbb\nccc»\nddd"]); cx.assert_editor_state(indoc! {" ˇbbb ccc bbb ccc"}); cx.dispatch_action(SelectPrevious::default()); cx.assert_editor_state(indoc! {" «bbbˇ» ccc bbb ccc"}); cx.dispatch_action(SelectPrevious::default()); cx.assert_editor_state(indoc! {" «bbbˇ» ccc «bbbˇ» ccc"}); } #[gpui::test] async fn test_select_previous_with_single_caret(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("abc\nˇabc abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); } #[gpui::test] async fn test_select_previous_empty_buffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("aˇ"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«aˇ»"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«aˇ»"); } #[gpui::test] async fn test_select_previous_with_multiple_carets(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( r#"let foo = 2; lˇet foo = 2; let fooˇ = 2; let foo = 2; let foo = ˇ2;"#, ); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state( r#"let foo = 2; «letˇ» foo = 2; let «fooˇ» = 2; let foo = 2; let foo = «2ˇ»;"#, ); // noop for multiple selections with different contents cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state( r#"let foo = 2; «letˇ» foo = 2; let «fooˇ» = 2; let foo = 2; let foo = «2ˇ»;"#, ); } #[gpui::test] async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Enable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(true); settings.search = Some(search_settings); }); cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); // selection direction is preserved cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\nabc"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\n«ˇabc»"); cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\nabc"); cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\n«ˇabc»"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndef«ˇabc»\n«ˇabc»"); cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) .unwrap(); cx.assert_editor_state("«ˇabc»\n«ˇabc» «ˇabc»\ndef«ˇabc»\n«ˇabc»"); // Test case sensitivity cx.set_state("foo\nFOO\nFoo\n«ˇfoo»"); cx.update_editor(|e, window, cx| { e.select_previous(&SelectPrevious::default(), window, cx) .unwrap(); e.select_previous(&SelectPrevious::default(), window, cx) .unwrap(); }); cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); // Disable case sensitive search. update_test_editor_settings(&mut cx, &|settings| { let mut search_settings = SearchSettingsContent::default(); search_settings.case_sensitive = Some(false); settings.search = Some(search_settings); }); cx.set_state("foo\nFOO\n«ˇFoo»"); cx.update_editor(|e, window, cx| { e.select_previous(&SelectPrevious::default(), window, cx) .unwrap(); e.select_previous(&SelectPrevious::default(), window, cx) .unwrap(); }); cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); } #[gpui::test] async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "text"; } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 25)..DisplayPoint::new(DisplayRow(0), 25), DisplayPoint::new(DisplayRow(2), 24)..DisplayPoint::new(DisplayRow(2), 12), DisplayPoint::new(DisplayRow(3), 18)..DisplayPoint::new(DisplayRow(3), 18), ]); }); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, «mod4ˇ»}; fn fn_1«ˇ(param1: bool, param2: &str)» { let var1 = "«textˇ»"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::«{mod3, mod4}ˇ»; «ˇfn fn_1(param1: bool, param2: &str) { let var1 = "text"; }» "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); assert_eq!( editor.update(cx, |editor, cx| editor .selections .display_ranges(&editor.display_snapshot(cx))), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); // Trying to expand the selected syntax node one more time has no effect. editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); assert_eq!( editor.update(cx, |editor, cx| editor .selections .display_ranges(&editor.display_snapshot(cx))), &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.update_in(cx, |editor, window, cx| { editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::«{mod3, mod4}ˇ»; «ˇfn fn_1(param1: bool, param2: &str) { let var1 = "text"; }» "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, «mod4ˇ»}; fn fn_1«ˇ(param1: bool, param2: &str)» { let var1 = "«textˇ»"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, moˇd4}; fn fn_1(para«ˇm1: bool, pa»ram2: &str) { let var1 = "teˇxt"; } "#}, cx, ); }); // Trying to shrink the selected syntax node one more time has no effect. editor.update_in(cx, |editor, window, cx| { editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); }); editor.update_in(cx, |editor, _, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, moˇd4}; fn fn_1(para«ˇm1: bool, pa»ram2: &str) { let var1 = "teˇxt"; } "#}, cx, ); }); // Ensure that we keep expanding the selection if the larger selection starts or ends within // a fold. editor.update_in(cx, |editor, window, cx| { editor.fold_creases( vec![ Crease::simple( Point::new(0, 21)..Point::new(0, 24), FoldPlaceholder::test(), ), Crease::simple( Point::new(3, 20)..Point::new(3, 22), FoldPlaceholder::test(), ), ], true, window, cx, ); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::«{mod3, mod4}ˇ»; fn fn_1«ˇ(param1: bool, param2: &str)» { let var1 = "«textˇ»"; } "#}, cx, ); }); // Ensure multiple cursors have consistent direction after expanding editor.update_in(cx, |editor, window, cx| { editor.unfold_all(&UnfoldAll, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 25)..DisplayPoint::new(DisplayRow(0), 25), DisplayPoint::new(DisplayRow(3), 18)..DisplayPoint::new(DisplayRow(3), 18), ]); }); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, «mod4ˇ»}; fn fn_1(param1: bool, param2: &str) { let var1 = "«textˇ»"; } "#}, cx, ); }); } #[gpui::test] async fn test_select_larger_syntax_node_for_cursor_at_end(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = "let a = 2;"; let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test case 1: Cursor at end of word editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, "let aˇ = 2;", cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, "let «ˇa» = 2;", cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, "«ˇlet a = 2;»", cx); }); // Test case 2: Cursor at end of statement editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, "let a = 2;ˇ", cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, "«ˇlet a = 2;»", cx); }); } #[gpui::test] async fn test_select_larger_syntax_node_for_cursor_at_symbol(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )); let text = r#" let a = { key: "value", }; "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test case 1: Cursor after '{' editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = {ˇ key: "value", }; "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = «ˇ{ key: "value", }»; "#}, cx, ); }); // Test case 2: Cursor after ':' editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 8)..DisplayPoint::new(DisplayRow(1), 8) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = { key:ˇ "value", }; "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = { «ˇkey: "value"», }; "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = «ˇ{ key: "value", }»; "#}, cx, ); }); // Test case 3: Cursor after ',' editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 17)..DisplayPoint::new(DisplayRow(1), 17) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = { key: "value",ˇ }; "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = «ˇ{ key: "value", }»; "#}, cx, ); }); // Test case 4: Cursor after ';' editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let a = { key: "value", };ˇ "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" «ˇlet a = { key: "value", }; »"#}, cx, ); }); } #[gpui::test] async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "hello world"; } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test 1: Cursor on a letter of a string word editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 17)..DisplayPoint::new(DisplayRow(3), 17) ]); }); }); editor.update_in(cx, |editor, window, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "hˇello world"; } "#}, cx, ); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "«ˇhello» world"; } "#}, cx, ); }); // Test 2: Partial selection within a word editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 17)..DisplayPoint::new(DisplayRow(3), 19) ]); }); }); editor.update_in(cx, |editor, window, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "h«elˇ»lo world"; } "#}, cx, ); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "«ˇhello» world"; } "#}, cx, ); }); // Test 3: Complete word already selected editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 21) ]); }); }); editor.update_in(cx, |editor, window, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "«helloˇ» world"; } "#}, cx, ); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "«hello worldˇ»"; } "#}, cx, ); }); // Test 4: Selection spanning across words editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 19)..DisplayPoint::new(DisplayRow(3), 24) ]); }); }); editor.update_in(cx, |editor, window, cx| { assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "hel«lo woˇ»rld"; } "#}, cx, ); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { let var1 = "«ˇhello world»"; } "#}, cx, ); }); // Test 5: Expansion beyond string editor.update_in(cx, |editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); assert_text_with_selections( editor, indoc! {r#" use mod1::mod2::{mod3, mod4}; fn fn_1(param1: bool, param2: &str) { «ˇlet var1 = "hello world";» } "#}, cx, ); }); } #[gpui::test] async fn test_unwrap_syntax_nodes(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state(indoc! { r#"use mod1::{mod2::{«mod3ˇ», mod4}, mod5::{mod6, «mod7ˇ»}};"# }); cx.update_editor(|editor, window, cx| { editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); }); cx.assert_editor_state(indoc! { r#"use mod1::{mod2::«mod3ˇ», mod5::«mod7ˇ»};"# }); cx.set_state(indoc! { r#"fn a() { // what // a // ˇlong // method // I // sure // hope // it // works }"# }); let buffer = cx.update_multibuffer(|multibuffer, _| multibuffer.as_singleton().unwrap()); let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); cx.update(|_, cx| { multi_buffer.update(cx, |multi_buffer, cx| { multi_buffer.set_excerpts_for_path( PathKey::for_buffer(&buffer, cx), buffer, [Point::new(1, 0)..Point::new(1, 0)], 3, cx, ); }); }); let editor2 = cx.new_window_entity(|window, cx| { Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); let mut cx = EditorTestContext::for_editor_in(editor2, &mut cx).await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(3, 0)..Point::new(3, 0)]); }) }); cx.assert_editor_state(indoc! { " fn a() { // what // a ˇ // long // method"}); cx.update_editor(|editor, window, cx| { editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); }); // Although we could potentially make the action work when the syntax node // is half-hidden, it seems a bit dangerous as you can't easily tell what it // did. Maybe we could also expand the excerpt to contain the range? cx.assert_editor_state(indoc! { " fn a() { // what // a ˇ // long // method"}); } #[gpui::test] async fn test_fold_function_bodies(cx: &mut TestAppContext) { init_test(cx, |_| {}); let base_text = r#" impl A { // this is an uncommitted comment fn b() { c(); } // this is another uncommitted comment fn d() { // e // f } } fn g() { // h } "# .unindent(); let text = r#" ˇimpl A { fn b() { c(); } fn d() { // e // f } } fn g() { // h } "# .unindent(); let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; cx.set_state(&text); cx.set_head_text(&base_text); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( " ˇimpl A { - // this is an uncommitted comment fn b() { c(); } - // this is another uncommitted comment - fn d() { // e // f } } fn g() { // h } " .unindent(), ); let expected_display_text = " impl A { // this is an uncommitted comment fn b() { ⋯ } // this is another uncommitted comment fn d() { ⋯ } } fn g() { ⋯ } " .unindent(); cx.update_editor(|editor, window, cx| { editor.fold_function_bodies(&FoldFunctionBodies, window, cx); assert_eq!(editor.display_text(cx), expected_display_text); }); } #[gpui::test] async fn test_autoindent(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: false, surround: false, newline: true, }, ], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query( r#" (_ "(" ")" @end) @indent (_ "{" "}" @end) @indent "#, ) .unwrap(), ); let text = "fn a() {}"; let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffset(5)..MultiBufferOffset(5), MultiBufferOffset(8)..MultiBufferOffset(8), MultiBufferOffset(9)..MultiBufferOffset(9), ]) }); editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 4)..Point::new(1, 4), Point::new(3, 4)..Point::new(3, 4), Point::new(5, 0)..Point::new(5, 0) ] ); }); } #[gpui::test] async fn test_autoindent_disabled(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::None) }); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: false, surround: false, newline: true, }, ], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query( r#" (_ "(" ")" @end) @indent (_ "{" "}" @end) @indent "#, ) .unwrap(), ); let text = "fn a() {}"; let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ MultiBufferOffset(5)..MultiBufferOffset(5), MultiBufferOffset(8)..MultiBufferOffset(8), MultiBufferOffset(9)..MultiBufferOffset(9), ]) }); editor.newline(&Newline, window, cx); assert_eq!( editor.text(cx), indoc!( " fn a( ) { } " ) ); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 0)..Point::new(1, 0), Point::new(3, 0)..Point::new(3, 0), Point::new(5, 0)..Point::new(5, 0) ] ); }); } #[gpui::test] async fn test_autoindent_none_does_not_preserve_indentation_on_newline(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::None) }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" hello indented lineˇ world "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.assert_editor_state(indoc! {" hello indented line ˇ world "}); } #[gpui::test] async fn test_autoindent_preserve_indent_maintains_indentation_on_newline(cx: &mut TestAppContext) { // When auto_indent is "preserve_indent", pressing Enter on an indented line // should preserve the indentation but not adjust based on syntax. init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent) }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" hello indented lineˇ world "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); // The new line SHOULD have the same indentation as the previous line cx.assert_editor_state(indoc! {" hello indented line ˇ world "}); } #[gpui::test] async fn test_autoindent_preserve_indent_does_not_apply_syntax_indent(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent) }); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: false, // Disable extra newline behavior to isolate syntax indent test }], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "{" "}" @end) @indent"#) .unwrap(), ); let buffer = cx.new(|cx| Buffer::local("fn foo() {\n}", cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Position cursor at end of line containing `{` editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(10)..MultiBufferOffset(10)]) // After "fn foo() {" }); editor.newline(&Newline, window, cx); // With PreserveIndent, the new line should have 0 indentation (same as the fn line) // NOT 4 spaces (which tree-sitter would add for being inside `{}`) assert_eq!(editor.text(cx), "fn foo() {\n\n}"); }); } #[gpui::test] async fn test_autoindent_syntax_aware_applies_syntax_indent(cx: &mut TestAppContext) { // Companion test to show that SyntaxAware DOES apply tree-sitter indentation init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware) }); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: false, // Disable extra newline behavior to isolate syntax indent test }], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "{" "}" @end) @indent"#) .unwrap(), ); let buffer = cx.new(|cx| Buffer::local("fn foo() {\n}", cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Position cursor at end of line containing `{` editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(10)..MultiBufferOffset(10)]) // After "fn foo() {" }); editor.newline(&Newline, window, cx); // With SyntaxAware, tree-sitter adds indentation for being inside `{}` assert_eq!(editor.text(cx), "fn foo() {\n \n}"); }); } #[gpui::test] async fn test_ime_composition_keeps_manual_indent(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware) }); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: false, }], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query(r#"(_ "{" "}" @end) @indent"#) .unwrap(), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state("fn foo() {ˇ\n}"); cx.condition(|editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); // A manual indent here editor.tab(&Tab, window, cx); }); assert_eq!(cx.buffer_text(), "fn foo() {\n \n}"); cx.update_editor(|editor, window, cx| { editor.replace_and_mark_text_in_range(None, "n", None, window, cx); editor.replace_and_mark_text_in_range(None, "nn", None, window, cx); }); cx.run_until_parked(); assert_eq!(cx.buffer_text(), "fn foo() {\n nn\n}"); } #[gpui::test] async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware); settings.languages.0.insert( "python".into(), LanguageSettingsContent { auto_indent: Some(settings::AutoIndentMode::None), ..Default::default() }, ); }); let mut cx = EditorTestContext::new(cx).await; let injected_language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: false, newline: true, }, ], ..Default::default() }, name: "python".into(), ..Default::default() }, Some(tree_sitter_python::LANGUAGE.into()), ) .with_indents_query( r#" (_ "(" ")" @end) @indent (_ "{" "}" @end) @indent "#, ) .unwrap(), ); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: false, newline: true, }, ], ..Default::default() }, name: LanguageName::new_static("rust"), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query( r#" (_ "(" ")" @end) @indent (_ "{" "}" @end) @indent "#, ) .unwrap() .with_injection_query( r#" (macro_invocation macro: (identifier) @_macro_name (token_tree) @injection.content (#set! injection.language "python")) "#, ) .unwrap(), ); cx.language_registry().add(injected_language); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state(r#"struct A {ˇ}"#); cx.update_editor(|editor, window, cx| { editor.newline(&Default::default(), window, cx); }); cx.assert_editor_state(indoc!( "struct A { ˇ }" )); cx.set_state(r#"select_biased!(ˇ)"#); cx.update_editor(|editor, window, cx| { editor.newline(&Default::default(), window, cx); editor.handle_input("def ", window, cx); editor.handle_input("(", window, cx); editor.newline(&Default::default(), window, cx); editor.handle_input("a", window, cx); }); cx.assert_editor_state(indoc!( "select_biased!( def ( aˇ ) )" )); } #[gpui::test] async fn test_autoindent_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); { let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; cx.set_state(indoc! {" impl A { fn b() {} «fn c() { }ˇ» } "}); cx.update_editor(|editor, window, cx| { editor.autoindent(&Default::default(), window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" impl A { fn b() {} «fn c() { }ˇ» } "}); } { let mut cx = EditorTestContext::new_multibuffer( cx, [indoc! { " impl A { « // a fn b(){} » « } fn c(){} » "}], ); let buffer = cx.update_editor(|editor, _, cx| { let buffer = editor.buffer().update(cx, |buffer, _| { buffer.all_buffers().iter().next().unwrap().clone() }); buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); buffer }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.select_all(&Default::default(), window, cx); editor.autoindent(&Default::default(), window, cx) }); cx.run_until_parked(); cx.update(|_, cx| { assert_eq!( buffer.read(cx).text(), indoc! { " impl A { // a fn b(){} } fn c(){} " } ) }); } } #[gpui::test] async fn test_autoclose_nested_brackets_in_plain_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = PLAIN_TEXT.clone(); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state("ˇ"); // Typing an opening bracket right before an existing closing bracket should // still autoclose, not skip because the following char is `)`. cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); editor.handle_input("[", window, cx); editor.handle_input("{", window, cx); }); cx.assert_editor_state("([{ˇ}])"); } #[gpui::test] async fn test_autoclose_and_auto_surround_pairs(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "/*".to_string(), end: " */".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "[".to_string(), end: "]".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "\"".to_string(), end: "\"".to_string(), close: true, surround: true, newline: false, }, BracketPair { start: "<".to_string(), end: ">".to_string(), close: false, surround: true, newline: true, }, ], ..Default::default() }, autoclose_before: "})]".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state( &r#" 🏀ˇ εˇ ❤️ˇ "# .unindent(), ); // autoclose multiple nested brackets at multiple cursors cx.update_editor(|editor, window, cx| { editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); }); cx.assert_editor_state( &" 🏀{{{ˇ}}} ε{{{ˇ}}} ❤️{{{ˇ}}} " .unindent(), ); // insert a different closing bracket cx.update_editor(|editor, window, cx| { editor.handle_input(")", window, cx); }); cx.assert_editor_state( &" 🏀{{{)ˇ}}} ε{{{)ˇ}}} ❤️{{{)ˇ}}} " .unindent(), ); // skip over the auto-closed brackets when typing a closing bracket cx.update_editor(|editor, window, cx| { editor.move_right(&MoveRight, window, cx); editor.handle_input("}", window, cx); editor.handle_input("}", window, cx); editor.handle_input("}", window, cx); }); cx.assert_editor_state( &" 🏀{{{)}}}}ˇ ε{{{)}}}}ˇ ❤️{{{)}}}}ˇ " .unindent(), ); // autoclose multi-character pairs cx.set_state( &" ˇ ˇ " .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("/", window, cx); editor.handle_input("*", window, cx); }); cx.assert_editor_state( &" /*ˇ */ /*ˇ */ " .unindent(), ); // one cursor autocloses a multi-character pair, one cursor // does not autoclose. cx.set_state( &" /ˇ ˇ " .unindent(), ); cx.update_editor(|editor, window, cx| editor.handle_input("*", window, cx)); cx.assert_editor_state( &" /*ˇ */ *ˇ " .unindent(), ); // Don't autoclose if the next character isn't whitespace and isn't // listed in the language's "autoclose_before" section. cx.set_state("ˇa b"); cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); cx.assert_editor_state("{ˇa b"); // Don't autoclose if `close` is false for the bracket pair cx.set_state("ˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("[", window, cx)); cx.assert_editor_state("[ˇ"); // Surround with brackets if text is selected cx.set_state("«aˇ» b"); cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); cx.assert_editor_state("{«aˇ»} b"); // Autoclose when not immediately after a word character cx.set_state("a ˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); cx.assert_editor_state("a \"ˇ\""); // Autoclose pair where the start and end characters are the same cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); cx.assert_editor_state("a \"\"ˇ"); // Don't autoclose when immediately after a word character cx.set_state("aˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); cx.assert_editor_state("a\"ˇ"); // Do autoclose when after a non-word character cx.set_state("{ˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); cx.assert_editor_state("{\"ˇ\""); // Non identical pairs autoclose regardless of preceding character cx.set_state("aˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); cx.assert_editor_state("a{ˇ}"); // Don't autoclose pair if autoclose is disabled cx.set_state("ˇ"); cx.update_editor(|editor, window, cx| editor.handle_input("<", window, cx)); cx.assert_editor_state("<ˇ"); // Surround with brackets if text is selected and auto_surround is enabled, even if autoclose is disabled cx.set_state("«aˇ» b"); cx.update_editor(|editor, window, cx| editor.handle_input("<", window, cx)); cx.assert_editor_state("<«aˇ»> b"); } #[gpui::test] async fn test_always_treat_brackets_as_autoclosed_skip_over(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.always_treat_brackets_as_autoclosed = Some(true); }); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "[".to_string(), end: "]".to_string(), close: false, surround: false, newline: true, }, ], ..Default::default() }, autoclose_before: "})]".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state( &" ˇ ˇ ˇ " .unindent(), ); // ensure only matching closing brackets are skipped over cx.update_editor(|editor, window, cx| { editor.handle_input("}", window, cx); editor.move_left(&MoveLeft, window, cx); editor.handle_input(")", window, cx); editor.move_left(&MoveLeft, window, cx); }); cx.assert_editor_state( &" ˇ)} ˇ)} ˇ)} " .unindent(), ); // skip-over closing brackets at multiple cursors cx.update_editor(|editor, window, cx| { editor.handle_input(")", window, cx); editor.handle_input("}", window, cx); }); cx.assert_editor_state( &" )}ˇ )}ˇ )}ˇ " .unindent(), ); // ignore non-close brackets cx.update_editor(|editor, window, cx| { editor.handle_input("]", window, cx); editor.move_left(&MoveLeft, window, cx); editor.handle_input("]", window, cx); }); cx.assert_editor_state( &" )}]ˇ] )}]ˇ] )}]ˇ] " .unindent(), ); } #[gpui::test] async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let html_language = Arc::new( Language::new( LanguageConfig { name: "HTML".into(), brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "<".into(), end: ">".into(), close: true, ..Default::default() }, BracketPair { start: "{".into(), end: "}".into(), close: true, ..Default::default() }, BracketPair { start: "(".into(), end: ")".into(), close: true, ..Default::default() }, ], ..Default::default() }, autoclose_before: "})]>".into(), ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), ) .with_injection_query( r#" (script_element (raw_text) @injection.content (#set! injection.language "javascript")) "#, ) .unwrap(), ); let javascript_language = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "/*".into(), end: " */".into(), close: true, ..Default::default() }, BracketPair { start: "{".into(), end: "}".into(), close: true, ..Default::default() }, BracketPair { start: "(".into(), end: ")".into(), close: true, ..Default::default() }, ], ..Default::default() }, autoclose_before: "})]>".into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )); cx.language_registry().add(html_language.clone()); cx.language_registry().add(javascript_language); cx.executor().run_until_parked(); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(html_language), cx); }); cx.set_state( &r#" ˇ ˇ "# .unindent(), ); // Precondition: different languages are active at different locations. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let cursors = editor .selections .ranges::(&editor.display_snapshot(cx)); let languages = cursors .iter() .map(|c| snapshot.language_at(c.start).unwrap().name()) .collect::>(); assert_eq!( languages, &[ LanguageName::from("HTML"), LanguageName::from("JavaScript"), LanguageName::from("HTML"), ] ); }); // Angle brackets autoclose in HTML, but not JavaScript. cx.update_editor(|editor, window, cx| { editor.handle_input("<", window, cx); editor.handle_input("a", window, cx); }); cx.assert_editor_state( &r#" "# .unindent(), ); // Curly braces and parens autoclose in both HTML and JavaScript. cx.update_editor(|editor, window, cx| { editor.handle_input(" b=", window, cx); editor.handle_input("{", window, cx); editor.handle_input("c", window, cx); editor.handle_input("(", window, cx); }); cx.assert_editor_state( &r#" "# .unindent(), ); // Brackets that were already autoclosed are skipped. cx.update_editor(|editor, window, cx| { editor.handle_input(")", window, cx); editor.handle_input("d", window, cx); editor.handle_input("}", window, cx); }); cx.assert_editor_state( &r#" "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input(">", window, cx); }); cx.assert_editor_state( &r#" ˇ ˇ "# .unindent(), ); // Reset cx.set_state( &r#" ˇ ˇ "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("<", window, cx); }); cx.assert_editor_state( &r#" <ˇ> <ˇ> "# .unindent(), ); // When backspacing, the closing angle brackets are removed. cx.update_editor(|editor, window, cx| { editor.backspace(&Backspace, window, cx); }); cx.assert_editor_state( &r#" ˇ ˇ "# .unindent(), ); // Block comments autoclose in JavaScript, but not HTML. cx.update_editor(|editor, window, cx| { editor.handle_input("/", window, cx); editor.handle_input("*", window, cx); }); cx.assert_editor_state( &r#" /*ˇ /*ˇ "# .unindent(), ); } #[gpui::test] async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let rust_language = Arc::new( Language::new( LanguageConfig { name: "Rust".into(), brackets: serde_json::from_value(json!([ { "start": "{", "end": "}", "close": true, "newline": true }, { "start": "\"", "end": "\"", "close": true, "newline": false, "not_in": ["string"] }, ])) .unwrap(), autoclose_before: "})]>".into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_override_query("(string_literal) @string") .unwrap(), ); cx.language_registry().add(rust_language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(rust_language), cx); }); cx.set_state( &r#" let x = ˇ "# .unindent(), ); // Inserting a quotation mark. A closing quotation mark is automatically inserted. cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state( &r#" let x = "ˇ" "# .unindent(), ); // Inserting another quotation mark. The cursor moves across the existing // automatically-inserted quotation mark. cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state( &r#" let x = ""ˇ "# .unindent(), ); // Reset cx.set_state( &r#" let x = ˇ "# .unindent(), ); // Inserting a quotation mark inside of a string. A second quotation mark is not inserted. cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); editor.handle_input(" ", window, cx); editor.move_left(&Default::default(), window, cx); editor.handle_input("\\", window, cx); editor.handle_input("\"", window, cx); }); cx.assert_editor_state( &r#" let x = "\"ˇ " "# .unindent(), ); // Inserting a closing quotation mark at the position of an automatically-inserted quotation // mark. Nothing is inserted. cx.update_editor(|editor, window, cx| { editor.move_right(&Default::default(), window, cx); editor.handle_input("\"", window, cx); }); cx.assert_editor_state( &r#" let x = "\" "ˇ "# .unindent(), ); } #[gpui::test] async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // Double quote inside single-quoted string cx.set_state(indoc! {r#" def main(): items = ['"', ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ['"', "ˇ"] "#}); // Two double quotes inside single-quoted string cx.set_state(indoc! {r#" def main(): items = ['""', ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ['""', "ˇ"] "#}); // Single quote inside double-quoted string cx.set_state(indoc! {r#" def main(): items = ["'", ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("'", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ["'", 'ˇ'] "#}); // Two single quotes inside double-quoted string cx.set_state(indoc! {r#" def main(): items = ["''", ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("'", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ["''", 'ˇ'] "#}); // Mixed quotes on same line cx.set_state(indoc! {r#" def main(): items = ['"""', "'''''", ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ['"""', "'''''", "ˇ"] "#}); cx.update_editor(|editor, window, cx| { editor.move_right(&MoveRight, window, cx); }); cx.update_editor(|editor, window, cx| { editor.handle_input(", ", window, cx); }); cx.update_editor(|editor, window, cx| { editor.handle_input("'", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ['"""', "'''''", "", 'ˇ'] "#}); } #[gpui::test] async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {r#" def main(): items = ["🎉", ˇ] "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.assert_editor_state(indoc! {r#" def main(): items = ["🎉", "ˇ"] "#}); } #[gpui::test] async fn test_surround_backticks_in_rust(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { let language = languages::language("rust", tree_sitter_rust::LANGUAGE.into()); buffer.set_language(Some(language), cx) }); // Surround a selection inside a doc comment with backticks cx.set_state(indoc! {" /// «Aˇ» fn main() {} "}); cx.update_editor(|editor, window, cx| { editor.handle_input("`", window, cx); }); cx.assert_editor_state(indoc! {" /// `«Aˇ»` fn main() {} "}); // When inside a string literal, the backtick pair is disabled so typing a // backtick should replace the selection instead of surrounding it. cx.set_state(indoc! {r#" fn main() { let name = "«Jesper Kouthoofdˇ»"; } "#}); cx.update_editor(|editor, window, cx| { editor.handle_input("`", window, cx); }); cx.assert_editor_state(indoc! {r#" fn main() { let name = "`ˇ"; } "#}); } #[gpui::test] async fn test_surround_with_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "/* ".to_string(), end: "*/".to_string(), close: true, surround: true, ..Default::default() }, ], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" a b c "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1), ]) }); editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); assert_eq!( editor.text(cx), " {{{a}}} {{{b}}} {{{c}}} " .unindent() ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 4), DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 4), DisplayPoint::new(DisplayRow(2), 3)..DisplayPoint::new(DisplayRow(2), 4) ] ); editor.undo(&Undo, window, cx); editor.undo(&Undo, window, cx); editor.undo(&Undo, window, cx); assert_eq!( editor.text(cx), " a b c " .unindent() ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1) ] ); // Ensure inserting the first character of a multi-byte bracket pair // doesn't surround the selections with the bracket. editor.handle_input("/", window, cx); assert_eq!( editor.text(cx), " / / / " .unindent() ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1) ] ); editor.undo(&Undo, window, cx); assert_eq!( editor.text(cx), " a b c " .unindent() ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1) ] ); // Ensure inserting the last character of a multi-byte bracket pair // doesn't surround the selections with the bracket. editor.handle_input("*", window, cx); assert_eq!( editor.text(cx), " * * * " .unindent() ); assert_eq!( display_ranges(editor, cx), [ DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1) ] ); }); } #[gpui::test] async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }], ..Default::default() }, autoclose_before: "}".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" a b c "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), Point::new(2, 1)..Point::new(2, 1), ]) }); editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); editor.handle_input("_", window, cx); assert_eq!( editor.text(cx), " a{{_}} b{{_}} c{{_}} " .unindent() ); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 4)..Point::new(0, 4), Point::new(1, 4)..Point::new(1, 4), Point::new(2, 4)..Point::new(2, 4) ] ); editor.backspace(&Default::default(), window, cx); editor.backspace(&Default::default(), window, cx); assert_eq!( editor.text(cx), " a{} b{} c{} " .unindent() ); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 2)..Point::new(0, 2), Point::new(1, 2)..Point::new(1, 2), Point::new(2, 2)..Point::new(2, 2) ] ); editor.delete_to_previous_word_start(&Default::default(), window, cx); assert_eq!( editor.text(cx), " a b c " .unindent() ); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), Point::new(2, 1)..Point::new(2, 1) ] ); }); } #[gpui::test] async fn test_always_treat_brackets_as_autoclosed_delete(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.always_treat_brackets_as_autoclosed = Some(true); }); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "[".to_string(), end: "]".to_string(), close: false, surround: true, newline: true, }, ], ..Default::default() }, autoclose_before: "})]".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state( &" {(ˇ)} [[ˇ]] {(ˇ)} " .unindent(), ); cx.update_editor(|editor, window, cx| { editor.backspace(&Default::default(), window, cx); editor.backspace(&Default::default(), window, cx); }); cx.assert_editor_state( &" ˇ ˇ]] ˇ " .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("{", window, cx); editor.handle_input("{", window, cx); editor.move_right(&MoveRight, window, cx); editor.move_right(&MoveRight, window, cx); editor.move_left(&MoveLeft, window, cx); editor.move_left(&MoveLeft, window, cx); editor.backspace(&Default::default(), window, cx); }); cx.assert_editor_state( &" {ˇ} {ˇ}]] {ˇ} " .unindent(), ); cx.update_editor(|editor, window, cx| { editor.backspace(&Default::default(), window, cx); }); cx.assert_editor_state( &" ˇ ˇ]] ˇ " .unindent(), ); } #[gpui::test] async fn test_auto_replace_emoji_shortcode(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let buffer = cx.new(|cx| Buffer::local("", cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.set_auto_replace_emoji_shortcode(true); editor.handle_input("Hello ", window, cx); editor.handle_input(":wave", window, cx); assert_eq!(editor.text(cx), "Hello :wave".unindent()); editor.handle_input(":", window, cx); assert_eq!(editor.text(cx), "Hello 👋".unindent()); editor.handle_input(" :smile", window, cx); assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent()); editor.handle_input(":", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄".unindent()); // Ensure shortcode gets replaced when it is part of a word that only consists of emojis editor.handle_input(":wave", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄:wave".unindent()); editor.handle_input(":", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄👋".unindent()); editor.handle_input(":1", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄👋:1".unindent()); editor.handle_input(":", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄👋:1:".unindent()); // Ensure shortcode does not get replaced when it is part of a word editor.handle_input(" Test:wave", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave".unindent()); editor.handle_input(":", window, cx); assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave:".unindent()); editor.set_auto_replace_emoji_shortcode(false); // Ensure shortcode does not get replaced when auto replace is off editor.handle_input(" :wave", window, cx); assert_eq!( editor.text(cx), "Hello 👋 😄👋:1: Test:wave: :wave".unindent() ); editor.handle_input(":", window, cx); assert_eq!( editor.text(cx), "Hello 👋 😄👋:1: Test:wave: :wave:".unindent() ); }); } #[gpui::test] async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (text, insertion_ranges) = marked_text_ranges( indoc! {" ˇ "}, false, ); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); _ = editor.update_in(cx, |editor, window, cx| { let snippet = Snippet::parse("type ${1|,i32,u32|} = $2").unwrap(); editor .insert_snippet( &insertion_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>(), snippet, window, cx, ) .unwrap(); fn assert(editor: &mut Editor, cx: &mut Context, marked_text: &str) { let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); assert_eq!(editor.text(cx), expected_text); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), selection_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>() ); } assert( editor, cx, indoc! {" type «» =• "}, ); assert!(editor.context_menu_visible(), "There should be a matches"); }); } #[gpui::test] async fn test_snippet_choices_menu_survives_completion_refresh(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (text, insertion_ranges) = marked_text_ranges( indoc! {" ˇ "}, false, ); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); _ = editor.update_in(cx, |editor, window, cx| { let snippet = Snippet::parse("type ${1|i32,u32|} = $2").unwrap(); editor .insert_snippet( &insertion_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>(), snippet, window, cx, ) .unwrap(); assert!( editor.context_menu_visible(), "Snippet choices menu should be visible after inserting the choice tabstop" ); editor.open_or_update_completions_menu(None, None, false, window, cx); assert!( editor.context_menu_visible(), "Snippet choices menu should remain visible after a completion refresh with an empty query" ); }); } #[gpui::test] async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppContext) { init_test(cx, |_| {}); fn assert_state(editor: &mut Editor, cx: &mut Context, marked_text: &str) { let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); assert_eq!(editor.text(cx), expected_text); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), selection_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>() ); } let (text, insertion_ranges) = marked_text_ranges( indoc! {" ˇ "}, false, ); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); _ = editor.update_in(cx, |editor, window, cx| { let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap(); editor .insert_snippet( &insertion_ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>(), snippet, window, cx, ) .unwrap(); assert_state( editor, cx, indoc! {" type «» = ;• "}, ); assert!( editor.context_menu_visible(), "Context menu should be visible for placeholder choices" ); editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); assert_state( editor, cx, indoc! {" type = «»;• "}, ); assert!( !editor.context_menu_visible(), "Context menu should be hidden after moving to next tabstop" ); editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); assert_state( editor, cx, indoc! {" type = ; ˇ "}, ); editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); assert_state( editor, cx, indoc! {" type = ; ˇ "}, ); }); _ = editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.backspace(&Backspace, window, cx); let snippet = Snippet::parse("fn ${1|,foo,bar|} = ${2:value}; $3").unwrap(); let insertion_ranges = editor .selections .all(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect::>(); editor .insert_snippet(&insertion_ranges, snippet, window, cx) .unwrap(); assert_state(editor, cx, "fn «» = value;•"); assert!( editor.context_menu_visible(), "Context menu should be visible for placeholder choices" ); editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); assert_state(editor, cx, "fn = «valueˇ»;•"); editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); assert_state(editor, cx, "fn «» = value;•"); assert!( editor.context_menu_visible(), "Context menu should be visible again after returning to first tabstop" ); editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); assert_state(editor, cx, "fn «» = value;•"); }); } #[gpui::test] async fn test_snippets(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" a.ˇ b a.ˇ b a.ˇ b "}); cx.update_editor(|editor, window, cx| { let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); let insertion_ranges = editor .selections .all(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect::>(); editor .insert_snippet(&insertion_ranges, snippet, window, cx) .unwrap(); }); cx.assert_editor_state(indoc! {" a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b "}); // Can't move earlier than the first tab stop cx.update_editor(|editor, window, cx| { assert!(!editor.move_to_prev_snippet_tabstop(window, cx)) }); cx.assert_editor_state(indoc! {" a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b "}); cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); cx.assert_editor_state(indoc! {" a.f(one, «twoˇ», three) b a.f(one, «twoˇ», three) b a.f(one, «twoˇ», three) b "}); cx.update_editor(|editor, window, cx| assert!(editor.move_to_prev_snippet_tabstop(window, cx))); cx.assert_editor_state(indoc! {" a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b a.f(«oneˇ», two, «threeˇ») b "}); cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); cx.assert_editor_state(indoc! {" a.f(one, «twoˇ», three) b a.f(one, «twoˇ», three) b a.f(one, «twoˇ», three) b "}); cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); cx.assert_editor_state(indoc! {" a.f(one, two, three)ˇ b a.f(one, two, three)ˇ b a.f(one, two, three)ˇ b "}); // As soon as the last tab stop is reached, snippet state is gone cx.update_editor(|editor, window, cx| { assert!(!editor.move_to_prev_snippet_tabstop(window, cx)) }); cx.assert_editor_state(indoc! {" a.f(one, two, three)ˇ b a.f(one, two, three)ˇ b a.f(one, two, three)ˇ b "}); } #[gpui::test] async fn test_snippet_indentation(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, window, cx| { let snippet = Snippet::parse(indoc! {" /* * Multiline comment with leading indentation * * $1 */ $0"}) .unwrap(); let insertion_ranges = editor .selections .all(&editor.display_snapshot(cx)) .iter() .map(|s| s.range()) .collect::>(); editor .insert_snippet(&insertion_ranges, snippet, window, cx) .unwrap(); }); cx.assert_editor_state(indoc! {" /* * Multiline comment with leading indentation * * ˇ */ "}); cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); cx.assert_editor_state(indoc! {" /* * Multiline comment with leading indentation * *• */ ˇ"}); } #[gpui::test] async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, _, cx| { editor.project().unwrap().update(cx, |project, cx| { project.snippets().update(cx, |snippets, _cx| { let snippet = project::snippet_provider::Snippet { prefix: vec!["multi word".to_string()], body: "this is many words".to_string(), description: Some("description".to_string()), name: "multi-word snippet test".to_string(), }; snippets.add_snippet_for_test( None, PathBuf::from("test_snippets.json"), vec![Arc::new(snippet)], ); }); }) }); for (input_to_simulate, should_match_snippet) in [ ("m", true), ("m ", true), ("m w", true), ("aa m w", true), ("aa m g", false), ] { cx.set_state("ˇ"); cx.simulate_input(input_to_simulate); // fails correctly cx.update_editor(|editor, _, _| { let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow() else { assert!(!should_match_snippet); // no completions! don't even show the menu return; }; assert!(context_menu.visible()); let completions = context_menu.completions.borrow(); assert_eq!(!completions.is_empty(), should_match_snippet); }); } } #[gpui::test] async fn test_read_only_buffer_is_not_formatted_or_saved(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec()) .await; let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let format_request_count = Arc::new(AtomicUsize::new(0)); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let format_request_count = format_request_count.clone(); move |fake_server| { let format_request_count = format_request_count.clone(); fake_server.set_request_handler::( move |_, _| { let format_request_count = format_request_count.clone(); async move { format_request_count.fetch_add(1, atomic::Ordering::Release); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(0, 3), lsp::Position::new(1, 0), ), ", ".to_string(), )])) } }, ); } })), ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); fake_servers.next().await.unwrap(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!(format_request_count.load(atomic::Ordering::Acquire), 0); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\ntwo\nthree\n" ); assert_eq!( fs.load(path!("/file.rs").as_ref()).await.unwrap(), "one\ntwo\nthree\n" ); } #[gpui::test] async fn test_read_only_buffer_can_be_saved_as(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file.rs": "one\ntwo\nthree\n", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/file.rs"), cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); let save_as = editor.update_in(cx, |editor, window, cx| { editor.save_as( project.clone(), ProjectPath { worktree_id, path: rel_path("copy.rs").into(), }, window, cx, ) }); save_as.await.unwrap(); assert_eq!( fs.load(path!("/dir/copy.rs").as_ref()).await.unwrap(), "one\ntwo\nthree\n" ); assert_eq!( fs.load(path!("/dir/file.rs").as_ref()).await.unwrap(), "one\ntwo\nthree\n" ); } #[gpui::test] async fn test_format_and_save_skip_read_only_buffers_in_multi_buffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "one\ntwo\nthree\n", "other.rs": "one\ntwo\nthree\n", }), ) .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let formatted_uris = Arc::new(Mutex::new(Vec::new())); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let formatted_uris = formatted_uris.clone(); move |fake_server| { let formatted_uris = formatted_uris.clone(); fake_server.set_request_handler::( move |params, _| { let formatted_uris = formatted_uris.clone(); async move { formatted_uris.lock().push(params.text_document.uri.clone()); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(0, 3), lsp::Position::new(1, 0), ), ", ".to_string(), )])) } }, ); } })), ..FakeLspAdapter::default() }, ); let writable_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) }) .await .unwrap(); let read_only_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/other.rs"), cx) }) .await .unwrap(); read_only_buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), writable_buffer.clone(), [Point::new(0, 0)..Point::new(3, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), read_only_buffer.clone(), [Point::new(0, 0)..Point::new(3, 0)], 0, cx, ); multi_buffer }); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multi_buffer, window, cx) }); fake_servers.next().await.unwrap(); let format = editor .update_in(cx, |editor, window, cx| editor.format(&Format, window, cx)) .unwrap(); format.await.unwrap(); assert_eq!( *formatted_uris.lock(), vec![lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap()] ); writable_buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "one, two\nthree\n"); }); read_only_buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "one\ntwo\nthree\n"); }); read_only_buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, "dirty ")], None, cx); }); assert_eq!( read_only_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), true ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: false, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!( fs.load(path!("/a/main.rs").as_ref()).await.unwrap(), "one, two\nthree\n" ); assert_eq!( fs.load(path!("/a/other.rs").as_ref()).await.unwrap(), "one\ntwo\nthree\n" ); } #[gpui::test] async fn test_save_actions_are_hidden_for_read_only_files(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec()) .await; let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); window .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); }) }) .unwrap(); cx.run_until_parked(); fn save_actions_visible(cx: &mut VisualTestContext) -> (bool, bool, bool, bool) { let actions = cx.update(|window, cx| window.available_actions(cx)); let visible = |name: &str| actions.iter().any(|action| action.name() == name); ( visible("workspace::Save"), visible("workspace::FormatAndSave"), visible("workspace::SaveWithoutFormat"), visible("workspace::SaveAs"), ) } assert_eq!(save_actions_visible(cx), (true, true, true, true)); buffer.update(cx, |buffer, cx| buffer.set_capability(ReadOnly, cx)); cx.run_until_parked(); assert_eq!(save_actions_visible(cx), (false, false, false, true)); buffer.update(cx, |buffer, cx| buffer.set_capability(ReadWrite, cx)); cx.run_until_parked(); assert_eq!(save_actions_visible(cx), (true, true, true, true)); } #[gpui::test] async fn test_document_format_during_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), Default::default()).await; let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let fake_server = fake_servers.next().await.unwrap(); { fake_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), ", ".to_string(), )])) }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one, two\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } { editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); // Ensure we can still save even if formatting hangs. fake_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); cx.executor().advance_clock(super::FORMAT_TIMEOUT); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\ntwo\nthree\n" ); } // Set rust language override and assert overridden tabsize is sent to language server update_test_language_settings(cx, &|settings| { settings.languages.0.insert( "Rust".into(), LanguageSettingsContent { tab_size: NonZeroU32::new(8), ..Default::default() }, ); }); { editor.update_in(cx, |editor, window, cx| { editor.set_text("somehting_new\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let _formatting_request_signal = fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 8); Ok(Some(vec![])) }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; } } #[gpui::test] async fn test_auto_formatter_skips_server_without_formatting(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), Default::default()).await; let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); // First server: no formatting capability let mut no_format_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "no-format-server", capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions::default()), ..Default::default() }, ..Default::default() }, ); // Second server: has formatting capability let mut format_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "format-server", capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); let _no_format_server = no_format_servers.next().await.unwrap(); let format_server = format_servers.next().await.unwrap(); format_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), ", ".to_string(), )])) }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one, two\nthree\n" ); } #[gpui::test] async fn test_redo_after_noop_format(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.ensure_final_newline_on_save = Some(false); }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.txt"), "foo".into()).await; let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.txt"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); }); assert!(!cx.read(|cx| editor.is_dirty(cx))); editor.update_in(cx, |editor, window, cx| { editor.handle_input("\n", window, cx) }); cx.run_until_parked(); save(&editor, &project, cx).await; assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx))); editor.update_in(cx, |editor, window, cx| { editor.undo(&Default::default(), window, cx); }); save(&editor, &project, cx).await; assert_eq!("foo", editor.read_with(cx, |editor, cx| editor.text(cx))); editor.update_in(cx, |editor, window, cx| { editor.redo(&Default::default(), window, cx); }); cx.run_until_parked(); assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx))); async fn save(editor: &Entity, project: &Entity, cx: &mut VisualTestContext) { let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert!(!cx.read(|cx| editor.is_dirty(cx))); } } #[gpui::test] async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); let cols = 4; let rows = 10; let sample_text_1 = sample_text(rows, cols, 'a'); assert_eq!( sample_text_1, "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" ); let sample_text_2 = sample_text(rows, cols, 'l'); assert_eq!( sample_text_2, "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" ); let sample_text_3 = sample_text(rows, cols, 'v').replace('\u{7f}', "."); assert_eq!( sample_text_3, "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n...." ); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text_1, "other.rs": sample_text_2, "lib.rs": sample_text_3, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, ..Default::default() }, ); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer_1 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("other.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("lib.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [ Point::new(0, 0)..Point::new(2, 4), Point::new(5, 0)..Point::new(6, 4), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [ Point::new(0, 0)..Point::new(2, 4), Point::new(5, 0)..Point::new(6, 4), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [ Point::new(0, 0)..Point::new(2, 4), Point::new(5, 0)..Point::new(6, 4), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); assert_eq!(multi_buffer.read(cx).excerpts().count(), 9); multi_buffer }); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); multi_buffer_editor.update_in(cx, |editor, window, cx| { let a = editor.text(cx).find("aaaa").unwrap(); editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(a + 1)..MultiBufferOffset(a + 2))), ); editor.insert("|one|two|three|", window, cx); }); assert!(cx.read(|cx| multi_buffer_editor.is_dirty(cx))); multi_buffer_editor.update_in(cx, |editor, window, cx| { let n = editor.text(cx).find("nnnn").unwrap(); editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(n + 4)..MultiBufferOffset(n + 14))), ); editor.insert("|four|five|six|", window, cx); }); assert!(cx.read(|cx| multi_buffer_editor.is_dirty(cx))); // First two buffers should be edited, but not the third one. pretty_assertions::assert_eq!( editor_content_with_blocks(&multi_buffer_editor, cx), indoc! {" § main.rs § ----- a|one|two|three|aa bbbb cccc § ----- ffff gggg § ----- jjjj § other.rs § ----- llll mmmm nnnn|four|five|six| § ----- § ----- uuuu § lib.rs § ----- vvvv wwww xxxx § ----- {{{{ |||| § ----- ...."} ); buffer_1.update(cx, |buffer, _| { assert!(buffer.is_dirty()); assert_eq!( buffer.text(), "a|one|two|three|aa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj", ) }); buffer_2.update(cx, |buffer, _| { assert!(buffer.is_dirty()); assert_eq!( buffer.text(), "llll\nmmmm\nnnnn|four|five|six|\noooo\npppp\n\nssss\ntttt\nuuuu", ) }); buffer_3.update(cx, |buffer, _| { assert!(!buffer.is_dirty()); assert_eq!(buffer.text(), sample_text_3,) }); cx.executor().run_until_parked(); let save = multi_buffer_editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); let fake_server = fake_servers.next().await.unwrap(); fake_server .server .on_request::(move |_params, _| async move { Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), "[formatted]".to_string(), )])) }) .detach(); save.await; // After multibuffer saving, only first two buffers should be reformatted, but not the third one (as it was not dirty). assert!(cx.read(|cx| !multi_buffer_editor.is_dirty(cx))); assert_eq!( editor_content_with_blocks(&multi_buffer_editor, cx), indoc! {" § main.rs § ----- a|o[formatted]bbbb cccc § ----- ffff gggg § ----- jjjj § other.rs § ----- lll[formatted]mmmm nnnn|four|five|six| § ----- § ----- uuuu § lib.rs § ----- vvvv wwww xxxx § ----- {{{{ |||| § ----- ...."} ); buffer_1.update(cx, |buffer, _| { assert!(!buffer.is_dirty()); assert_eq!( buffer.text(), "a|o[formatted]bbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n", ) }); // Diff < left / right > : // lll[formatted]mmmm // nnnn|four|five|six|oooo // pppp // < // ssss // tttt // uuuu buffer_2.update(cx, |buffer, _| { assert!(!buffer.is_dirty()); assert_eq!( buffer.text(), "lll[formatted]mmmm\nnnnn|four|five|six|\noooo\npppp\n\nssss\ntttt\nuuuu\n", ) }); buffer_3.update(cx, |buffer, _| { assert!(!buffer.is_dirty()); assert_eq!(buffer.text(), sample_text_3,) }); } #[gpui::test] async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1.rs": "fn main() { println!(\"hello\"); }", "file2.rs": "fn test() { println!(\"test\"); }", "file3.rs": "fn other() { println!(\"other\"); }\n", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); // Open three buffers let buffer_1 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("file1.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("file2.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("file3.rs")), cx) }) .await .unwrap(); // Create a multi-buffer with all three buffers let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(1, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(1, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [Point::new(0, 0)..Point::new(1, 0)], 0, cx, ); multi_buffer }); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); // Edit only the first buffer editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(10)..MultiBufferOffset(10))), ); editor.insert("// edited", window, cx); }); // Verify that only buffer 1 is dirty buffer_1.update(cx, |buffer, _| assert!(buffer.is_dirty())); buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty())); buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty())); // Get write counts after file creation (files were created with initial content) // We expect each file to have been written once during creation let write_count_after_creation_1 = fs.write_count_for_path(path!("/dir/file1.rs")); let write_count_after_creation_2 = fs.write_count_for_path(path!("/dir/file2.rs")); let write_count_after_creation_3 = fs.write_count_for_path(path!("/dir/file3.rs")); // Perform autosave let save_task = editor.update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: true, }, project.clone(), window, cx, ) }); save_task.await.unwrap(); // Only the dirty buffer should have been saved assert_eq!( fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1, 1, "Buffer 1 was dirty, so it should have been written once during autosave" ); assert_eq!( fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2, 0, "Buffer 2 was clean, so it should not have been written during autosave" ); assert_eq!( fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3, 0, "Buffer 3 was clean, so it should not have been written during autosave" ); // Verify buffer states after autosave buffer_1.update(cx, |buffer, _| assert!(!buffer.is_dirty())); buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty())); buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty())); // Now perform a manual save (format = true) let save_task = editor.update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }); save_task.await.unwrap(); // During manual save, clean buffers don't get written to disk // They just get did_save called for language server notifications assert_eq!( fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1, 1, "Buffer 1 should only have been written once total (during autosave, not manual save)" ); assert_eq!( fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2, 0, "Buffer 2 should not have been written at all" ); assert_eq!( fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3, 0, "Buffer 3 should not have been written at all" ); } #[gpui::test] async fn test_format_selections_action_available_when_range_formatting_is_supported( cx: &mut TestAppContext, ) { let (_, editor, cx, _) = setup_range_format_test(cx).await; editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); }); }); refresh_editor_actions(cx); assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); } #[gpui::test] async fn test_format_selections_action_available_for_cursor_when_range_formatting_is_supported( cx: &mut TestAppContext, ) { let (_, editor, cx, _) = setup_range_format_test(cx).await; editor.update_in(cx, |editor, window, cx| { editor.set_text("foo\nbar\n", window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(1, 1)..Point::new(1, 1)]); }); }); refresh_editor_actions(cx); assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); } #[gpui::test] async fn test_format_selections_action_hidden_without_range_formatting_support( cx: &mut TestAppContext, ) { let (_, editor, cx, _) = setup_range_format_test_with_capabilities( cx, lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), document_range_formatting_provider: Some(lsp::OneOf::Left(false)), ..lsp::ServerCapabilities::default() }, ) .await; editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); }); }); refresh_editor_actions(cx); assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); } #[gpui::test] async fn test_format_selections_action_hidden_without_range_capable_formatter( cx: &mut TestAppContext, ) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::External { command: "awk".into(), arguments: Some(vec!["{ print }".to_string()]), })); }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), Default::default()).await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let _ = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); }); }); refresh_editor_actions(cx); assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); } #[gpui::test] async fn test_range_format_on_save_success(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), ", ".to_string(), )])) }) .next() .await; save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one, two\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } #[gpui::test] async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); // Test that save still works when formatting hangs fake_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); cx.executor().advance_clock(super::FORMAT_TIMEOUT); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\ntwo\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } #[gpui::test] async fn test_modifications_format_on_save(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() ); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), ", ".to_string(), )])) }) .next() .await; save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one, two\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } #[gpui::test] async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!( cx.read(|cx| editor.is_dirty(cx)), "editor should be dirty before save" ); let _no_range_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("rangeFormatting must not be called when no git diff is available"); }) .next(); let _no_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("full formatting must not be called for Modifications without a git diff"); }) .next(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; cx.run_until_parked(); assert!( !cx.read(|cx| editor.is_dirty(cx)), "the buffer should be saved despite the skipped formatting" ); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\ntwo\nthree\n" ); } #[gpui::test] async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "file.rs": "", }), ) .await; let head_content = "one\ntwo\nthree\n"; fs.set_head_and_index_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], ); let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(false)), document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/file.rs"), cx) }) .await .unwrap(); project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); let fake_server = fake_servers.next().await.unwrap(); update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\nTWO\nthree\n", window, cx) }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let _no_range_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("rangeFormatting must not be called when range formatting is unsupported"); }) .next(); let _no_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("full formatting must not be called for Modifications when range formatting is unsupported"); }) .next(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; cx.run_until_parked(); assert!(!cx.read(|cx| editor.is_dirty(cx))); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\nTWO\nthree\n" ); } #[gpui::test] async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx) }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() ); Ok(Some(Vec::new())) }) .next() .await; save.await; assert!(!cx.read(|cx| editor.is_dirty(cx))); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "aaa\nbbb\nccc\nddd\neee\n" ); } #[gpui::test] async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx); }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); let mut responded_rx = fake_server.set_request_handler::(move |params, _| { let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() ); match count { 0 => Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)), "!".to_string(), )])), 1 => Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)), "!".to_string(), )])), _ => panic!("unexpected third range formatting request"), } } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; responded_rx.next().await; save.await; assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } #[gpui::test] async fn test_modifications_format_overlapping_range_responses(cx: &mut TestAppContext) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", vec![vec![lsp_line_edit(0, 6, "fmt0\nfmt1\nfmt2\nfmt3\nfmt4\nfmt5\n")]], "fmt0\nfmt1\nfmt2\nfmt3\nfmt4\nfmt5\n", "a response covering the second hunk must suppress its request and must not duplicate the formatted block", ) .await; } #[gpui::test] async fn test_modifications_format_divergent_overlapping_responses(cx: &mut TestAppContext) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", vec![ vec![lsp_line_edit(1, 3, "one\ntwo\n")], vec![lsp_line_edit(2, 5, "TWO\nthree\nfour\n")], ], "line0\none\ntwo\nline3\nLINE4\nline5\n", "the first edit must win and the divergent overlapping edit must be dropped without corrupting the buffer", ) .await; } #[gpui::test] async fn test_modifications_format_partially_overlapping_response_dropped_atomically( cx: &mut TestAppContext, ) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", vec![ vec![lsp_line_edit(1, 2, "one\n")], vec![ lsp_line_edit(4, 5, "four\n"), lsp_line_edit(1, 2, "ONE\n"), ], ], "line0\none\nline2\nline3\nLINE4\nline5\n", "a response with any divergent overlapping edit must be dropped whole, including its non-overlapping edits", ) .await; } #[gpui::test] async fn test_modifications_format_overlapping_edits_within_one_response(cx: &mut TestAppContext) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nline4\nline5\n", vec![vec![ lsp_line_edit(1, 2, "one\n"), lsp_line_edit(1, 2, "uno\n"), ]], "line0\none\nline2\nline3\nline4\nline5\n", "the later overlapping edit within one response must be dropped", ) .await; } #[gpui::test] async fn test_modifications_format_same_position_inserts_within_one_response( cx: &mut TestAppContext, ) { assert_range_format_merge( cx, "line0\nline1\nline2\n", "line0\nLINE1\nline2\n", vec![vec![ lsp_line_edit(1, 1, "alpha\n"), lsp_line_edit(1, 1, "beta\n"), ]], "line0\nalpha\nbeta\nLINE1\nline2\n", "multiple inserts at one position within one response are valid per the LSP spec and must all apply in array order", ) .await; } #[gpui::test] async fn test_modifications_format_divergent_same_position_inserts_across_responses( cx: &mut TestAppContext, ) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", vec![ vec![lsp_line_edit(4, 4, "alpha\n")], vec![lsp_line_edit(4, 4, "beta\n")], ], "line0\nLINE1\nline2\nline3\nalpha\nLINE4\nline5\n", "a later response inserting different text at the same position diverges and must be dropped", ) .await; } #[gpui::test] async fn test_modifications_format_insert_touching_kept_edit_across_responses( cx: &mut TestAppContext, ) { assert_range_format_merge( cx, "line0\nline1\nline2\nline3\nline4\nline5\n", "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", vec![ vec![ lsp_line_edit(1, 2, "one\n"), lsp_line_edit(4, 4, "alpha\n"), ], vec![lsp_line_edit(4, 5, "alpha\nfour\n")], ], "line0\none\nline2\nline3\nalpha\nLINE4\nline5\n", "an edit touching a kept insert at its boundary can duplicate the inserted text and must be dropped as divergent", ) .await; } #[gpui::test] async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\nline3\nline4\n"; let staged_content = "line0\nLINE1\nline2\nline3\nline4\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, staged_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; editor.update_in(cx, |editor, window, cx| { editor.set_text(working_content, window, cx) }); cx.run_until_parked(); let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); let mut responded_rx = fake_server.set_request_handler::(move |params, _| { request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); assert_eq!( params.range.start.line, 4, "only the unstaged hunk (LINE4) should be formatted" ); async move { Ok(Some(Vec::new())) } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; save.await; assert_eq!( request_count.load(atomic::Ordering::SeqCst), 1, "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)" ); } #[gpui::test] async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) { let head_content = "a\nb\nc\n"; let staged_content = "A\nb\nc\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, staged_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("A\nB\nc\n", window, cx) }); cx.run_until_parked(); let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX)); let captured_start_line_clone = captured_start_line.clone(); let mut responded_rx = fake_server.set_request_handler::(move |params, _| { captured_start_line_clone .store(params.range.start.line as usize, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; save.await; let start_line = captured_start_line.load(atomic::Ordering::SeqCst); assert_ne!( start_line, usize::MAX, "expected at least one range formatting request" ); assert_eq!( start_line, 1, "format range must exclude the staged row and start at the unstaged row" ); } #[gpui::test] async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\nline3\nline4\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; editor.update_in(cx, |editor, window, cx| { editor.set_text(working_content, window, cx) }); cx.run_until_parked(); let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); let mut responded_rx = fake_server.set_request_handler::( move |_params, _| { request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; responded_rx.next().await; save.await; assert_eq!( request_count.load(atomic::Ordering::SeqCst), 2, "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path" ); } #[gpui::test] async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\nline3\nline4\n"; let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, staged_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text(staged_content, window, cx) }); cx.run_until_parked(); let _no_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("range formatting must not be called when all changes are staged"); }) .next(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; cx.run_until_parked(); } #[gpui::test] async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text(head_content, window, cx) }); cx.run_until_parked(); let _no_format_handler = fake_server .set_request_handler::(move |_, _| async move { panic!("range formatting must not be called when buffer matches HEAD"); }) .next(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; cx.run_until_parked(); } #[gpui::test] async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n", }), ) .await; let head_content = "line0\nline1\nline2\nline3\nline4\n"; fs.set_head_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], "deadbeef", ); fs.set_index_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], ); let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/file.rs"), cx) }) .await .unwrap(); buffer.read_with(cx, |buffer, _| { assert_eq!( buffer.line_ending(), language::LineEnding::Windows, "buffer should detect CRLF line endings from the working tree file" ); }); project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); }); let fake_server = fake_servers.next().await.unwrap(); update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx) }); cx.run_until_parked(); let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); let mut responded_rx = fake_server.set_request_handler::( move |_params, _| { request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; responded_rx.next().await; save.await; assert_eq!( request_count.load(atomic::Ordering::SeqCst), 2, "CRLF line endings must not cause spurious diff hunks" ); } #[gpui::test] async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\nline3\nline4\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n"; editor.update_in(cx, |editor, window, cx| { editor.set_text(working_content, window, cx) }); cx.run_until_parked(); let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); let mut responded_rx = fake_server.set_request_handler::( move |_params, _| { request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }, ); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); responded_rx.next().await; responded_rx.next().await; save.await; assert_eq!( request_count.load(atomic::Ordering::SeqCst), 2, "hunks separated by exactly one unchanged row must not merge" ); } #[gpui::test] async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) { let head_content = "line0\nline1\nline2\n"; let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); }); editor.update_in(cx, |editor, window, cx| { editor.set_text(head_content, window, cx) }); cx.run_until_parked(); let _no_range_format = fake_server .set_request_handler::(move |_, _| async move { panic!("range formatting must not be called when diff is empty"); }) .next(); let _no_format = fake_server .set_request_handler::(move |_, _| async move { panic!("full formatting must not be called when a diff is available but empty"); }) .next(); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; cx.run_until_parked(); assert!( !cx.read(|cx| editor.is_dirty(cx)), "the buffer should be saved despite the skipped formatting" ); } #[gpui::test] async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities( cx, lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ) .await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let _no_range_format = fake_server .set_request_handler::(move |_, _| async move { panic!("range formatting must not be called when no git diff is available"); }) .next(); let formatting_called = Arc::new(AtomicBool::new(false)); let formatting_called_clone = formatting_called.clone(); let mut formatting_rx = fake_server.set_request_handler::(move |_, _| { formatting_called_clone.store(true, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); formatting_rx.next().await; save.await; assert!( formatting_called.load(atomic::Ordering::SeqCst), "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available" ); } #[gpui::test] async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "file.rs": "", }), ) .await; let head_content = "line0\nline1\nline2\n"; fs.set_head_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], "deadbeef", ); fs.set_index_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], ); let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(false)), document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/file.rs"), cx) }) .await .unwrap(); project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); }); let fake_server = fake_servers.next().await.unwrap(); update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("line0\nLINE1\nline2\n", window, cx); }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let _no_range_format = fake_server .set_request_handler::(move |_, _| async move { panic!("rangeFormatting must not be called when LSP lacks range support"); }) .next(); let formatting_called = Arc::new(AtomicBool::new(false)); let formatting_called_clone = formatting_called.clone(); let mut formatting_rx = fake_server.set_request_handler::(move |_, _| { formatting_called_clone.store(true, atomic::Ordering::SeqCst); async move { Ok(Some(Vec::new())) } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); formatting_rx.next().await; save.await; assert!( formatting_called.load(atomic::Ordering::SeqCst), "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support" ); } #[gpui::test] async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; // Buffer starts clean, no formatting should be requested let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: false, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); let _pending_format_request = fake_server .set_request_handler::(move |_, _| async move { panic!("Should not be invoked"); }) .next(); save.await; cx.run_until_parked(); } #[gpui::test] async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; // Set Rust language override and assert overridden tabsize is sent to language server update_test_language_settings(cx, &|settings| { settings.languages.0.insert( "Rust".into(), LanguageSettingsContent { tab_size: NonZeroU32::new(8), ..Default::default() }, ); }); editor.update_in(cx, |editor, window, cx| { editor.set_text("something_new\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 8); Ok(Some(Vec::new())) }) .next() .await; save.await; } #[gpui::test] async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::LanguageServer( settings::LanguageServerFormatterSpecifier::Current, ))) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), Default::default()).await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "Rust".into(), matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() }) .into(), ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ))); update_test_language_settings(cx, &|settings| { // Enable Prettier formatting for the same buffer, and ensure // LSP is called instead of Prettier. settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); let fake_server = fake_servers.next().await.unwrap(); let format = editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), ", ".to_string(), )])) }) .next() .await; format.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one, two\nthree\n" ); editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); // Ensure we don't lock if formatting hangs. fake_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() }, ); let format = editor .update_in(cx, |editor, window, cx| { editor.perform_format( project, FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap(); cx.executor().advance_clock(super::FORMAT_TIMEOUT); format.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "one\ntwo\nthree\n" ); } #[gpui::test] async fn test_multiple_formatters(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.remove_trailing_whitespace_on_save = Some(true); settings.defaults.formatter = Some(FormatterList::Vec(vec![ Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), Formatter::CodeAction("code-action-1".into()), Formatter::CodeAction("code-action-2".into()), ])) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), "one \ntwo \nthree".into()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), execute_command_provider: Some(lsp::ExecuteCommandOptions { commands: vec!["the-command-for-code-action-1".into()], ..Default::default() }), code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); let fake_server = fake_servers.next().await.unwrap(); fake_server.set_request_handler::( move |_params, _| async move { Ok(Some(vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "applied-formatting\n".to_string(), )])) }, ); fake_server.set_request_handler::( move |params, _| async move { let requested_code_actions = params.context.only.expect("Expected code action request"); assert_eq!(requested_code_actions.len(), 1); let uri = lsp::Uri::from_file_path(path!("/file.rs")).unwrap(); let code_action = match requested_code_actions[0].as_str() { "code-action-1" => lsp::CodeAction { kind: Some("code-action-1".into()), edit: Some(lsp::WorkspaceEdit::new( [( uri, vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "applied-code-action-1-edit\n".to_string(), )], )] .into_iter() .collect(), )), command: Some(lsp::Command { command: "the-command-for-code-action-1".into(), ..Default::default() }), ..Default::default() }, "code-action-2" => lsp::CodeAction { kind: Some("code-action-2".into()), edit: Some(lsp::WorkspaceEdit::new( [( uri, vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "applied-code-action-2-edit\n".to_string(), )], )] .into_iter() .collect(), )), ..Default::default() }, req => panic!("Unexpected code action request: {:?}", req), }; Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( code_action, )])) }, ); fake_server.set_request_handler::({ move |params, _| async move { Ok(params) } }); let command_lock = Arc::new(futures::lock::Mutex::new(())); fake_server.set_request_handler::({ let fake = fake_server.clone(); let lock = command_lock.clone(); move |params, _| { assert_eq!(params.command, "the-command-for-code-action-1"); let fake = fake.clone(); let lock = lock.clone(); async move { lock.lock().await; fake.server .request::( lsp::ApplyWorkspaceEditParams { label: None, edit: lsp::WorkspaceEdit { changes: Some( [( lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 0), ), new_text: "applied-code-action-1-command\n".into(), }], )] .into_iter() .collect(), ), ..Default::default() }, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); Ok(Some(json!(null))) } } }); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; editor.update(cx, |editor, cx| { assert_eq!( editor.text(cx), r#" applied-code-action-2-edit applied-code-action-1-command applied-code-action-1-edit applied-formatting one two three "# .unindent() ); }); editor.update_in(cx, |editor, window, cx| { editor.undo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "one \ntwo \nthree"); }); // Perform a manual edit while waiting for an LSP command // that's being run as part of a formatting code action. let lock_guard = command_lock.lock().await; let format = editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap(); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!( editor.text(cx), r#" applied-code-action-1-edit applied-formatting one two three "# .unindent() ); editor.buffer.update(cx, |buffer, cx| { let ix = buffer.len(cx); buffer.edit([(ix..ix, "edited\n")], None, cx); }); }); // Allow the LSP command to proceed. Because the buffer was edited, // the second code action will not be run. drop(lock_guard); format.await; editor.update_in(cx, |editor, window, cx| { assert_eq!( editor.text(cx), r#" applied-code-action-1-command applied-code-action-1-edit applied-formatting one two three edited "# .unindent() ); // The manual edit is undone first, because it is the last thing the user did // (even though the command completed afterwards). editor.undo(&Default::default(), window, cx); assert_eq!( editor.text(cx), r#" applied-code-action-1-command applied-code-action-1-edit applied-formatting one two three "# .unindent() ); // All the formatting (including the command, which completed after the manual edit) // is undone together. editor.undo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "one \ntwo \nthree"); }); } #[gpui::test] async fn test_markdown_save_respects_disabled_trailing_whitespace_removal(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.ensure_final_newline_on_save = Some(false); settings.languages.0.insert( "Markdown".into(), LanguageSettingsContent { remove_trailing_whitespace_on_save: Some(false), ..Default::default() }, ); }); let fs = FakeFs::new(cx.executor()); fs.insert_file( path!("/.editorconfig"), "root = true\n[*]\ntrim_trailing_whitespace = true\n".into(), ) .await; fs.insert_file(path!("/README.md"), "".into()).await; let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(markdown_lang()); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/README.md"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text("line with spaces \n", window, cx) }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "line with spaces \n" ); assert_eq!( fs.load(path!("/README.md").as_ref()) .await .unwrap() .replace("\r\n", "\n"), "line with spaces \n" ); } #[gpui::test] async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( settings::LanguageServerFormatterSpecifier::Current, )])) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.ts"), Default::default()).await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }) .into(), ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); update_test_language_settings(cx, &|settings| { settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let mut fake_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.ts"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text( "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n", window, cx, ) }); let fake_server = fake_servers.next().await.unwrap(); let format = editor .update_in(cx, |editor, window, cx| { editor.perform_code_action_kind( project.clone(), CodeActionKind::SOURCE_ORGANIZE_IMPORTS, window, cx, ) }) .unwrap(); fake_server .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.ts")).unwrap() ); Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { title: "Organize Imports".to_string(), kind: Some(lsp::CodeActionKind::SOURCE_ORGANIZE_IMPORTS), edit: Some(lsp::WorkspaceEdit { changes: Some( [( params.text_document.uri.clone(), vec![lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(1, 0), lsp::Position::new(2, 0), ), "".to_string(), )], )] .into_iter() .collect(), ), ..Default::default() }), ..Default::default() }, )])) }) .next() .await; format.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "import { a } from 'module';\n\nconst x = a;\n" ); editor.update_in(cx, |editor, window, cx| { editor.set_text( "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n", window, cx, ) }); // Ensure we don't lock if code action hangs. fake_server.set_request_handler::( move |params, _| async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/file.ts")).unwrap() ); futures::future::pending::<()>().await; unreachable!() }, ); let format = editor .update_in(cx, |editor, window, cx| { editor.perform_code_action_kind( project, CodeActionKind::SOURCE_ORGANIZE_IMPORTS, window, cx, ) }) .unwrap(); cx.executor().advance_clock(super::CODE_ACTION_TIMEOUT); format.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n" ); } #[gpui::test] async fn test_formatter_failure_does_not_abort_subsequent_formatters(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Vec(vec![ Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), Formatter::CodeAction("organize-imports".into()), ])) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); let fake_server = fake_servers.next().await.unwrap(); // Formatter #1 (LanguageServer) returns an error to simulate failure fake_server.set_request_handler::( move |_params, _| async move { Err(anyhow::anyhow!("Simulated formatter failure")) }, ); // Formatter #2 (CodeAction) returns a successful edit fake_server.set_request_handler::( move |_params, _| async move { let uri = lsp::Uri::from_file_path(path!("/file.rs")).unwrap(); Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { kind: Some("organize-imports".into()), edit: Some(lsp::WorkspaceEdit::new( [( uri, vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "use std::io;\n".to_string(), )], )] .into_iter() .collect(), )), ..Default::default() }, )])) }, ); fake_server.set_request_handler::({ move |params, _| async move { Ok(params) } }); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; // Formatter #1 (LanguageServer) failed, but formatter #2 (CodeAction) should have applied editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "use std::io;\nfn main() {}\n"); }); // The entire format operation should undo as one transaction editor.update_in(cx, |editor, window, cx| { editor.undo(&Default::default(), window, cx); assert_eq!(editor.text(cx), "fn main() {}\n"); }); } #[gpui::test] async fn test_explicit_formatter_failure_is_recorded(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( settings::LanguageServerFormatterSpecifier::Current, )])) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let format_should_fail = Arc::new(AtomicBool::new(true)); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let format_should_fail = format_should_fail.clone(); move |fake_server| { let format_should_fail = format_should_fail.clone(); fake_server.set_request_handler::( move |_params, _| { let format_should_fail = format_should_fail.clone(); async move { if format_should_fail.load(atomic::Ordering::Acquire) { Err(anyhow::anyhow!("Simulated formatter failure")) } else { Ok(Some(Vec::new())) } } }, ); } })), ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); fake_servers.next().await.unwrap(); let format = |editor: &Entity, cx: &mut VisualTestContext| { editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() }; let last_failure = |cx: &mut VisualTestContext| { project.read_with(cx, |project, cx| { project .lsp_store() .read(cx) .last_formatting_failure() .map(str::to_string) }) }; format(&editor, cx).await; assert!( last_failure(cx).is_some(), "a failing explicitly configured formatter should be recorded" ); format_should_fail.store(false, atomic::Ordering::Release); format(&editor, cx).await; assert_eq!( last_failure(cx), None, "A later successful format clears the recorded failure" ); } #[gpui::test] async fn test_auto_formatter_failure_is_silent(cx: &mut TestAppContext) { init_test(cx, |settings| { // `formatter: "auto"` with prettier disallowed resolves to the primary language server. settings.defaults.formatter = Some(FormatterList::Single(Formatter::Auto)); settings.defaults.prettier.get_or_insert_default().allowed = Some(false); }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(|fake_server| { fake_server.set_request_handler::( move |_params, _| async move { Err(anyhow::anyhow!("Simulated formatter failure")) }, ); })), ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); fake_servers.next().await.unwrap(); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; let last_failure = project.read_with(cx, |project, cx| { project .lsp_store() .read(cx) .last_formatting_failure() .map(str::to_string) }); assert_eq!( last_failure, None, "an auto-resolved formatter failure should not be surfaced" ); } #[gpui::test] async fn test_external_formatter_with_no_output_leaves_buffer_unchanged(cx: &mut TestAppContext) { init_test(cx, |settings| { #[cfg(windows)] let (command, arguments) = ("cmd", vec!["/C".to_string(), "more > nul".to_string()]); #[cfg(not(windows))] let (command, arguments) = ("sh", vec!["-c".to_string(), "cat >/dev/null".to_string()]); // Consume stdin without printing anything, mirroring tools like `cargo fmt` // that format files in place instead of writing to stdout. settings.defaults.formatter = Some(FormatterList::Single(Formatter::External { command: command.into(), arguments: Some(arguments), })); }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); // Formatting via an external command spawns a real subprocess, whose I/O // happens off the deterministic test scheduler. cx.executor().allow_parking(); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; editor.update(cx, |editor, cx| { assert_eq!( editor.text(cx), "fn main() {}\n", "buffer should be left unchanged when the external formatter produces no output" ); }); let last_failure = project.read_with(cx, |project, cx| { project .lsp_store() .read(cx) .last_formatting_failure() .map(str::to_string) }); assert_eq!( last_failure, None, "producing no output is not a formatter failure" ); } #[gpui::test] async fn test_concurrent_format_requests(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" one.twoˇ "}); // The format request takes a long time. When it completes, it inserts // a newline and an indent before the `.` cx.lsp .set_request_handler::(move |_, cx| { let executor = cx.background_executor().clone(); async move { executor.timer(Duration::from_millis(100)).await; Ok(Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 3)), new_text: "\n ".into(), }])) } }); // Submit a format request. let format_1 = cx .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) .unwrap(); cx.executor().run_until_parked(); // Submit a second format request. let format_2 = cx .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) .unwrap(); cx.executor().run_until_parked(); // Wait for both format requests to complete cx.executor().advance_clock(Duration::from_millis(200)); format_1.await.unwrap(); format_2.await.unwrap(); // The formatting edits only happens once. cx.assert_editor_state(indoc! {" one .twoˇ "}); } #[gpui::test] async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::default()) }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, cx, ) .await; // Record which buffer changes have been sent to the language server let buffer_changes = Arc::new(Mutex::new(Vec::new())); cx.lsp .handle_notification::({ let buffer_changes = buffer_changes.clone(); move |params, _| { buffer_changes.lock().extend( params .content_changes .into_iter() .map(|e| (e.range.unwrap(), e.text)), ); } }); // Handle formatting requests to the language server. cx.lsp .set_request_handler::({ move |_, _| { // Insert blank lines between each line of the buffer. async move { // TODO: this assertion is not reliably true. Currently nothing guarantees that we deliver // DidChangedTextDocument to the LSP before sending the formatting request. // assert_eq!( // &buffer_changes.lock()[1..], // &[ // ( // lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), // "".into() // ), // ( // lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), // "".into() // ), // ( // lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), // "\n".into() // ), // ] // ); Ok(Some(vec![ lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(1, 0), lsp::Position::new(1, 0), ), new_text: "\n".into(), }, lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(2, 0), lsp::Position::new(2, 0), ), new_text: "\n".into(), }, ])) } } }); // Set up a buffer white some trailing whitespace and no trailing newline. cx.set_state( &[ "one ", // "twoˇ", // "three ", // "four", // ] .join("\n"), ); // Submit a format request. let format = cx .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) .unwrap(); cx.run_until_parked(); // After formatting the buffer, the trailing whitespace is stripped, // a newline is appended, and the edits provided by the language server // have been applied. format.await.unwrap(); cx.assert_editor_state( &[ "one", // "", // "twoˇ", // "", // "three", // "four", // "", // ] .join("\n"), ); // Undoing the formatting undoes the trailing whitespace removal, the // trailing newline, and the LSP edits. cx.update_buffer(|buffer, cx| buffer.undo(cx)); cx.assert_editor_state( &[ "one ", // "twoˇ", // "three ", // "four", // ] .join("\n"), ); } #[gpui::test] async fn test_handle_input_for_show_signature_help_auto_signature_help_true( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(true); settings.editor.hover_popover_delay = Some(DelayMs(300)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; let language = Language::new( LanguageConfig { name: "Rust".into(), brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "/*".to_string(), end: " */".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "[".to_string(), end: "]".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "\"".to_string(), end: "\"".to_string(), close: true, surround: true, newline: false, }, BracketPair { start: "<".to_string(), end: ">".to_string(), close: false, surround: true, newline: true, }, ], ..Default::default() }, autoclose_before: "})]".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ); let language = Arc::new(language); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); cx.set_state( &r#" fn main() { sampleˇ } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); }); cx.assert_editor_state( &" fn main() { sample(ˇ) } " .unindent(), ); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { let signature_help_state = editor.signature_help_state.popover().cloned(); let signature = signature_help_state.unwrap(); assert_eq!( signature.signatures[signature.current_signature].label, "fn sample(param1: u8, param2: u8)" ); }); } #[gpui::test] async fn test_signature_help_delay_only_for_auto(cx: &mut TestAppContext) { init_test(cx, |_| {}); let delay_ms = 500; cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(true); settings.editor.show_signature_help_after_edits = Some(false); settings.editor.hover_popover_delay = Some(DelayMs(delay_ms)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8)".to_string(), documentation: None, parameters: Some(vec![lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; cx.set_state(indoc! {" fn main() { sample(ˇ); } fn sample(param1: u8) {} "}); // Manual trigger should show immediately without delay cx.update_editor(|editor, window, cx| { editor.show_signature_help(&ShowSignatureHelp, window, cx); }); handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.signature_help_state.is_shown(), "Manual trigger should show signature help without delay" ); }); cx.update_editor(|editor, _, cx| { editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); }); cx.run_until_parked(); cx.editor(|editor, _, _| { assert!(!editor.signature_help_state.is_shown()); }); // Auto trigger (cursor movement into brackets) should respect delay cx.set_state(indoc! {" fn main() { sampleˇ(); } fn sample(param1: u8) {} "}); cx.update_editor(|editor, window, cx| { editor.move_right(&MoveRight, window, cx); }); handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.run_until_parked(); cx.editor(|editor, _, _| { assert!( !editor.signature_help_state.is_shown(), "Auto trigger should wait for delay before showing signature help" ); }); cx.executor() .advance_clock(Duration::from_millis(delay_ms + 50)); cx.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.signature_help_state.is_shown(), "Auto trigger should show signature help after delay elapsed" ); }); } #[gpui::test] async fn test_signature_help_after_edits_no_delay(cx: &mut TestAppContext) { init_test(cx, |_| {}); let delay_ms = 500; cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(false); settings.editor.show_signature_help_after_edits = Some(true); settings.editor.hover_popover_delay = Some(DelayMs(delay_ms)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let language = Arc::new(Language::new( LanguageConfig { name: "Rust".into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }], ..BracketPairConfig::default() }, autoclose_before: "})".to_string(), ..LanguageConfig::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8)".to_string(), documentation: None, parameters: Some(vec![lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; cx.set_state(indoc! {" fn main() { sampleˇ } "}); // Typing bracket should show signature help immediately without delay cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); }); handle_signature_help_request(&mut cx, mocked_response).await; cx.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.signature_help_state.is_shown(), "show_signature_help_after_edits should show signature help without delay" ); }); } #[gpui::test] async fn test_handle_input_with_different_show_signature_settings(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(false); settings.editor.show_signature_help_after_edits = Some(false); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; let language = Language::new( LanguageConfig { name: "Rust".into(), brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "(".to_string(), end: ")".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "/*".to_string(), end: " */".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "[".to_string(), end: "]".to_string(), close: false, surround: false, newline: true, }, BracketPair { start: "\"".to_string(), end: "\"".to_string(), close: true, surround: true, newline: false, }, BracketPair { start: "<".to_string(), end: ">".to_string(), close: false, surround: true, newline: true, }, ], ..Default::default() }, autoclose_before: "})]".to_string(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ); let language = Arc::new(language); cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); // Ensure that signature_help is not called when no signature help is enabled. cx.set_state( &r#" fn main() { sampleˇ } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); }); cx.assert_editor_state( &" fn main() { sample(ˇ) } " .unindent(), ); cx.editor(|editor, _, _| { assert!(editor.signature_help_state.task().is_none()); }); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; // Ensure that signature_help is called when enabled afte edits cx.update(|_, cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(false); settings.editor.show_signature_help_after_edits = Some(true); }); }); }); cx.set_state( &r#" fn main() { sampleˇ } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); }); cx.assert_editor_state( &" fn main() { sample(ˇ) } " .unindent(), ); handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.update_editor(|editor, _, _| { let signature_help_state = editor.signature_help_state.popover().cloned(); assert!(signature_help_state.is_some()); let signature = signature_help_state.unwrap(); assert_eq!( signature.signatures[signature.current_signature].label, "fn sample(param1: u8, param2: u8)" ); editor.signature_help_state = SignatureHelpState::default(); }); // Ensure that signature_help is called when auto signature help override is enabled cx.update(|_, cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(true); settings.editor.show_signature_help_after_edits = Some(false); }); }); }); cx.set_state( &r#" fn main() { sampleˇ } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); }); cx.assert_editor_state( &" fn main() { sample(ˇ) } " .unindent(), ); handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { let signature_help_state = editor.signature_help_state.popover().cloned(); assert!(signature_help_state.is_some()); let signature = signature_help_state.unwrap(); assert_eq!( signature.signatures[signature.current_signature].label, "fn sample(param1: u8, param2: u8)" ); }); } #[gpui::test] async fn test_signature_help(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.auto_signature_help = Some(true); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; // A test that directly calls `show_signature_help` cx.update_editor(|editor, window, cx| { editor.show_signature_help(&ShowSignatureHelp, window, cx); }); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { let signature_help_state = editor.signature_help_state.popover().cloned(); assert!(signature_help_state.is_some()); let signature = signature_help_state.unwrap(); assert_eq!( signature.signatures[signature.current_signature].label, "fn sample(param1: u8, param2: u8)" ); }); // When exiting outside from inside the brackets, `signature_help` is closed. cx.set_state(indoc! {" fn main() { sample(ˇ); } fn sample(param1: u8, param2: u8) {} "}); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); }); let mocked_response = lsp::SignatureHelp { signatures: Vec::new(), active_signature: None, active_parameter: None, }; handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| !editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { assert!(!editor.signature_help_state.is_shown()); }); // When entering inside the brackets from outside, `show_signature_help` is automatically called. cx.set_state(indoc! {" fn main() { sample(ˇ); } fn sample(param1: u8, param2: u8) {} "}); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(0), }; handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { assert!(editor.signature_help_state.is_shown()); }); // Restore the popover with more parameter input cx.set_state(indoc! {" fn main() { sample(param1, param2ˇ); } fn sample(param1: u8, param2: u8) {} "}); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(1), }; handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; // When selecting a range, the popover is gone. // Avoid using `cx.set_state` to not actually edit the document, just change its selections. cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(1, 25)..Point::new(1, 19))); }) }); cx.assert_editor_state(indoc! {" fn main() { sample(param1, «ˇparam2»); } fn sample(param1: u8, param2: u8) {} "}); cx.editor(|editor, _, _| { assert!(!editor.signature_help_state.is_shown()); }); // When unselecting again, the popover is back if within the brackets. cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); }) }); cx.assert_editor_state(indoc! {" fn main() { sample(param1, ˇparam2); } fn sample(param1: u8, param2: u8) {} "}); handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.editor(|editor, _, _| { assert!(editor.signature_help_state.is_shown()); }); // Test to confirm that SignatureHelp does not appear after deselecting multiple ranges when it was hidden by pressing Escape. cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(0, 0)..Point::new(0, 0))); s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); }) }); cx.assert_editor_state(indoc! {" fn main() { sample(param1, ˇparam2); } fn sample(param1: u8, param2: u8) {} "}); let mocked_response = lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "fn sample(param1: u8, param2: u8)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param1: u8".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("param2: u8".to_string()), documentation: None, }, ]), active_parameter: None, }], active_signature: Some(0), active_parameter: Some(1), }; handle_signature_help_request(&mut cx, mocked_response.clone()).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; cx.update_editor(|editor, _, cx| { editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); }); cx.condition(|editor, _| !editor.signature_help_state.is_shown()) .await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(1, 25)..Point::new(1, 19))); }) }); cx.assert_editor_state(indoc! {" fn main() { sample(param1, «ˇparam2»); } fn sample(param1: u8, param2: u8) {} "}); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); }) }); cx.assert_editor_state(indoc! {" fn main() { sample(param1, ˇparam2); } fn sample(param1: u8, param2: u8) {} "}); cx.condition(|editor, _| !editor.signature_help_state.is_shown()) // because hidden by escape .await; } #[gpui::test] async fn test_signature_help_multiple_signatures(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { signature_help_provider: Some(lsp::SignatureHelpOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn main() { overloadedˇ } "}); cx.update_editor(|editor, window, cx| { editor.handle_input("(", window, cx); editor.show_signature_help(&ShowSignatureHelp, window, cx); }); // Mock response with 3 signatures let mocked_response = lsp::SignatureHelp { signatures: vec![ lsp::SignatureInformation { label: "fn overloaded(x: i32)".to_string(), documentation: None, parameters: Some(vec![lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("x: i32".to_string()), documentation: None, }]), active_parameter: None, }, lsp::SignatureInformation { label: "fn overloaded(x: i32, y: i32)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("x: i32".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("y: i32".to_string()), documentation: None, }, ]), active_parameter: None, }, lsp::SignatureInformation { label: "fn overloaded(x: i32, y: i32, z: i32)".to_string(), documentation: None, parameters: Some(vec![ lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("x: i32".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("y: i32".to_string()), documentation: None, }, lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("z: i32".to_string()), documentation: None, }, ]), active_parameter: None, }, ], active_signature: Some(1), active_parameter: Some(0), }; handle_signature_help_request(&mut cx, mocked_response).await; cx.condition(|editor, _| editor.signature_help_state.is_shown()) .await; // Verify we have multiple signatures and the right one is selected cx.editor(|editor, _, _| { let popover = editor.signature_help_state.popover().cloned().unwrap(); assert_eq!(popover.signatures.len(), 3); // active_signature was 1, so that should be the current assert_eq!(popover.current_signature, 1); assert_eq!(popover.signatures[0].label, "fn overloaded(x: i32)"); assert_eq!(popover.signatures[1].label, "fn overloaded(x: i32, y: i32)"); assert_eq!( popover.signatures[2].label, "fn overloaded(x: i32, y: i32, z: i32)" ); }); // Test navigation functionality cx.update_editor(|editor, window, cx| { editor.signature_help_next(&crate::SignatureHelpNext, window, cx); }); cx.editor(|editor, _, _| { let popover = editor.signature_help_state.popover().cloned().unwrap(); assert_eq!(popover.current_signature, 2); }); // Test wrap around cx.update_editor(|editor, window, cx| { editor.signature_help_next(&crate::SignatureHelpNext, window, cx); }); cx.editor(|editor, _, _| { let popover = editor.signature_help_state.popover().cloned().unwrap(); assert_eq!(popover.current_signature, 0); }); // Test previous navigation cx.update_editor(|editor, window, cx| { editor.signature_help_prev(&crate::SignatureHelpPrevious, window, cx); }); cx.editor(|editor, _, _| { let popover = editor.signature_help_state.popover().cloned().unwrap(); assert_eq!(popover.current_signature, 2); }); } #[gpui::test] async fn test_completion_mode(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; struct Run { run_description: &'static str, initial_state: String, buffer_marked_text: String, completion_label: &'static str, completion_text: &'static str, expected_with_insert_mode: String, expected_with_replace_mode: String, expected_with_replace_subsequence_mode: String, expected_with_replace_suffix_mode: String, } let runs = [ Run { run_description: "Start of word matches completion text", initial_state: "before ediˇ after".into(), buffer_marked_text: "before after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇ after".into(), expected_with_replace_mode: "before editorˇ after".into(), expected_with_replace_subsequence_mode: "before editorˇ after".into(), expected_with_replace_suffix_mode: "before editorˇ after".into(), }, Run { run_description: "Accept same text at the middle of the word", initial_state: "before ediˇtor after".into(), buffer_marked_text: "before after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇtor after".into(), expected_with_replace_mode: "before editorˇ after".into(), expected_with_replace_subsequence_mode: "before editorˇ after".into(), expected_with_replace_suffix_mode: "before editorˇ after".into(), }, Run { run_description: "End of word matches completion text -- cursor at end", initial_state: "before torˇ after".into(), buffer_marked_text: "before after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇ after".into(), expected_with_replace_mode: "before editorˇ after".into(), expected_with_replace_subsequence_mode: "before editorˇ after".into(), expected_with_replace_suffix_mode: "before editorˇ after".into(), }, Run { run_description: "End of word matches completion text -- cursor at start", initial_state: "before ˇtor after".into(), buffer_marked_text: "before <|tor> after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇtor after".into(), expected_with_replace_mode: "before editorˇ after".into(), expected_with_replace_subsequence_mode: "before editorˇ after".into(), expected_with_replace_suffix_mode: "before editorˇ after".into(), }, Run { run_description: "Prepend text containing whitespace", initial_state: "pˇfield: bool".into(), buffer_marked_text: ": bool".into(), completion_label: "pub ", completion_text: "pub ", expected_with_insert_mode: "pub ˇfield: bool".into(), expected_with_replace_mode: "pub ˇ: bool".into(), expected_with_replace_subsequence_mode: "pub ˇfield: bool".into(), expected_with_replace_suffix_mode: "pub ˇfield: bool".into(), }, Run { run_description: "Add element to start of list", initial_state: "[element_ˇelement_2]".into(), buffer_marked_text: "[]".into(), completion_label: "element_1", completion_text: "element_1", expected_with_insert_mode: "[element_1ˇelement_2]".into(), expected_with_replace_mode: "[element_1ˇ]".into(), expected_with_replace_subsequence_mode: "[element_1ˇelement_2]".into(), expected_with_replace_suffix_mode: "[element_1ˇelement_2]".into(), }, Run { run_description: "Add element to start of list -- first and second elements are equal", initial_state: "[elˇelement]".into(), buffer_marked_text: "[]".into(), completion_label: "element", completion_text: "element", expected_with_insert_mode: "[elementˇelement]".into(), expected_with_replace_mode: "[elementˇ]".into(), expected_with_replace_subsequence_mode: "[elementˇelement]".into(), expected_with_replace_suffix_mode: "[elementˇ]".into(), }, Run { run_description: "Ends with matching suffix", initial_state: "SubˇError".into(), buffer_marked_text: "".into(), completion_label: "SubscriptionError", completion_text: "SubscriptionError", expected_with_insert_mode: "SubscriptionErrorˇError".into(), expected_with_replace_mode: "SubscriptionErrorˇ".into(), expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), expected_with_replace_suffix_mode: "SubscriptionErrorˇ".into(), }, Run { run_description: "Suffix is a subsequence -- contiguous", initial_state: "SubˇErr".into(), buffer_marked_text: "".into(), completion_label: "SubscriptionError", completion_text: "SubscriptionError", expected_with_insert_mode: "SubscriptionErrorˇErr".into(), expected_with_replace_mode: "SubscriptionErrorˇ".into(), expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), expected_with_replace_suffix_mode: "SubscriptionErrorˇErr".into(), }, Run { run_description: "Suffix is a subsequence -- non-contiguous -- replace intended", initial_state: "Suˇscrirr".into(), buffer_marked_text: "".into(), completion_label: "SubscriptionError", completion_text: "SubscriptionError", expected_with_insert_mode: "SubscriptionErrorˇscrirr".into(), expected_with_replace_mode: "SubscriptionErrorˇ".into(), expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), expected_with_replace_suffix_mode: "SubscriptionErrorˇscrirr".into(), }, Run { run_description: "Suffix is a subsequence -- non-contiguous -- replace unintended", initial_state: "foo(indˇix)".into(), buffer_marked_text: "foo()".into(), completion_label: "node_index", completion_text: "node_index", expected_with_insert_mode: "foo(node_indexˇix)".into(), expected_with_replace_mode: "foo(node_indexˇ)".into(), expected_with_replace_subsequence_mode: "foo(node_indexˇix)".into(), expected_with_replace_suffix_mode: "foo(node_indexˇix)".into(), }, Run { run_description: "Replace range ends before cursor - should extend to cursor", initial_state: "before editˇo after".into(), buffer_marked_text: "before <{ed}>it|o after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇo after".into(), expected_with_replace_mode: "before editorˇo after".into(), expected_with_replace_subsequence_mode: "before editorˇo after".into(), expected_with_replace_suffix_mode: "before editorˇo after".into(), }, Run { run_description: "Uses label for suffix matching", initial_state: "before ediˇtor after".into(), buffer_marked_text: "before after".into(), completion_label: "editor", completion_text: "editor()", expected_with_insert_mode: "before editor()ˇtor after".into(), expected_with_replace_mode: "before editor()ˇ after".into(), expected_with_replace_subsequence_mode: "before editor()ˇ after".into(), expected_with_replace_suffix_mode: "before editor()ˇ after".into(), }, Run { run_description: "Case insensitive subsequence and suffix matching", initial_state: "before EDiˇtoR after".into(), buffer_marked_text: "before after".into(), completion_label: "editor", completion_text: "editor", expected_with_insert_mode: "before editorˇtoR after".into(), expected_with_replace_mode: "before editorˇ after".into(), expected_with_replace_subsequence_mode: "before editorˇ after".into(), expected_with_replace_suffix_mode: "before editorˇ after".into(), }, ]; for run in runs { let run_variations = [ (LspInsertMode::Insert, run.expected_with_insert_mode), (LspInsertMode::Replace, run.expected_with_replace_mode), ( LspInsertMode::ReplaceSubsequence, run.expected_with_replace_subsequence_mode, ), ( LspInsertMode::ReplaceSuffix, run.expected_with_replace_suffix_mode, ), ]; for (lsp_insert_mode, expected_text) in run_variations { eprintln!( "run = {:?}, mode = {lsp_insert_mode:.?}", run.run_description, ); update_test_language_settings(&mut cx, &|settings| { settings.defaults.completions = Some(CompletionSettingsContent { lsp_insert_mode: Some(lsp_insert_mode), words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), ..Default::default() }); }); cx.set_state(&run.initial_state); // Set up resolve handler before showing completions, since resolve may be // triggered when menu becomes visible (for documentation), not just on confirm. cx.set_request_handler::( move |_, _, _| async move { Ok(lsp::CompletionItem { additional_text_edits: None, ..Default::default() }) }, ); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); let counter = Arc::new(AtomicUsize::new(0)); handle_completion_request_with_insert_and_replace( &mut cx, &run.buffer_marked_text, vec![(run.completion_label, run.completion_text)], counter.clone(), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 1); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state(&expected_text); apply_additional_edits.await.unwrap(); } } } #[gpui::test] async fn test_completion_without_text_edit(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language_registry = cx.language_registry(); let language = rust_lang(); let completion_item = Arc::new(Mutex::new(None::)); let mut fake_servers = language_registry.register_fake_lsp( language.name(), FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions::default()), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let completion_item = completion_item.clone(); move |server| { let completion_item = completion_item.clone(); server.set_request_handler::(move |_, _| { let completion_item = completion_item .lock() .clone() .expect("completion item should be set before requesting completions"); async move { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: true, items: vec![completion_item], item_defaults: None, }))) } }); } })), ..FakeLspAdapter::default() }, ); language_registry.add(language.clone()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let _language_server = fake_servers .next() .await .expect("language server should start"); cx.executor().run_until_parked(); for (initial_state, completion_text, filter_text, expected_states) in [ ( "test(value1, ˇvalue2)", "value2=", "value2", [ "test(value1, value2=ˇvalue2)", "test(value1, value2=ˇ)", "test(value1, value2=ˇ)", "test(value1, value2=ˇvalue2)", ], ), ( "test(value1, valˇvalue2)", "value2=", "value2", [ "test(value1, value2=ˇvalue2)", "test(value1, value2=ˇ)", "test(value1, value2=ˇvalue2)", "test(value1, value2=ˇvalue2)", ], ), ( "ediˇtor", "editor", "edi", ["editorˇtor", "editorˇ", "editorˇ", "editorˇ"], ), ( "strˇing", "str", "str", ["strˇing", "strˇ", "strˇing", "strˇing"], ), ( "SuˇErr", "SubscriptionError", "Su", [ "SubscriptionErrorˇErr", "SubscriptionErrorˇ", "SubscriptionErrorˇ", "SubscriptionErrorˇErr", ], ), ( "test(value1, valueˇ)", "value2=", "value", ["test(value1, value2=ˇ)"; 4], ), ( "test(value1, ˇ)", "value2=", "v2", ["test(value1, value2=ˇ)"; 4], ), ( "test(value1, ˇv2)", "value2=", "v2", [ "test(value1, value2=ˇv2)", "test(value1, value2=ˇ)", "test(value1, value2=ˇ)", "test(value1, value2=ˇv2)", ], ), ( "test(\"😀\", 𐐀naïˇve)", "𐐀naïve", "𐐀naï", [ "test(\"😀\", 𐐀naïveˇve)", "test(\"😀\", 𐐀naïveˇ)", "test(\"😀\", 𐐀naïveˇ)", "test(\"😀\", 𐐀naïveˇ)", ], ), ( "test(\n\tˇvalue2\n)", "value2=", "value2", [ "test(\n\tvalue2=ˇvalue2\n)", "test(\n\tvalue2=ˇ\n)", "test(\n\tvalue2=ˇ\n)", "test(\n\tvalue2=ˇvalue2\n)", ], ), ( "ˇvalue2", "value2=", "value2", ["value2=ˇvalue2", "value2=ˇ", "value2=ˇ", "value2=ˇvalue2"], ), ("valueˇ", "value2=", "value", ["value2=ˇ"; 4]), ("ˇ", "value2=", "v2", ["value2=ˇ"; 4]), ] { for (lsp_insert_mode, expected_state) in [ LspInsertMode::Insert, LspInsertMode::Replace, LspInsertMode::ReplaceSubsequence, LspInsertMode::ReplaceSuffix, ] .into_iter() .zip(expected_states) { update_test_language_settings(&mut cx, &|settings| { settings.defaults.completions = Some(CompletionSettingsContent { lsp_insert_mode: Some(lsp_insert_mode), words: Some(WordsCompletionMode::Disabled), ..CompletionSettingsContent::default() }); }); for (insert_text, detail, description, use_filter_text) in [ (None, None, None, false), (Some(completion_text), None, None, false), (None, Some("string"), None, false), (Some(completion_text), Some("string"), None, false), (None, None, Some("string"), false), (Some(completion_text), None, Some("string"), false), (Some(completion_text), None, None, true), (Some(completion_text), Some(filter_text), None, true), ] { eprintln!( "{initial_state:?}, {lsp_insert_mode:?}, {insert_text:?}, {detail:?}, {description:?}, {use_filter_text:?}" ); cx.set_state(initial_state); *completion_item.lock() = Some(lsp::CompletionItem { label: completion_text.to_string(), filter_text: use_filter_text.then(|| filter_text.to_string()), insert_text: insert_text.map(str::to_string), detail: detail.map(str::to_string), label_details: description.map(|description| lsp::CompletionItemLabelDetails { detail: None, description: Some(description.to_string()), }), ..lsp::CompletionItem::default() }); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, window, cx| { editor.confirm_completion(&ConfirmCompletion::default(), window, cx) }) .expect("completion should be accepted") .await .expect("completion should succeed"); cx.assert_editor_state(expected_state); } } } } #[gpui::test] async fn test_completion_without_text_edit_after_typing(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.completions = Some(CompletionSettingsContent { lsp_insert_mode: Some(LspInsertMode::Replace), words: Some(WordsCompletionMode::Disabled), ..CompletionSettingsContent::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..lsp::CompletionOptions::default() }), ..lsp::ServerCapabilities::default() }, cx, ) .await; let requests = Arc::new(AtomicUsize::new(0)); cx.set_request_handler::({ let requests = requests.clone(); move |_, _, _| { requests.fetch_add(1, atomic::Ordering::Release); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "value2=".to_string(), insert_text: Some("value2=".to_string()), ..lsp::CompletionItem::default() }, ]))) } } }); let mut resolved = cx.set_request_handler::( |_, mut item, _| async move { item.documentation = Some(lsp::Documentation::String("resolved".to_string())); Ok(item) }, ); cx.set_state("test(value1, ˇvalue2)"); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.condition(|editor, _| editor.context_menu_visible()) .await; resolved.next().await.expect("completion should resolve"); cx.run_until_parked(); cx.simulate_input("v"); cx.run_until_parked(); cx.simulate_input("a"); cx.run_until_parked(); assert_eq!(requests.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("test(value1, vaˇvalue2)"); cx.update_editor(|editor, window, cx| { editor.confirm_completion_insert(&ConfirmCompletionInsert, window, cx) }) .expect("completion should be accepted") .await .expect("completion should succeed"); cx.assert_editor_state("test(value1, value2=ˇvalue2)"); } #[gpui::test] async fn test_completion_with_explicit_replace_range(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.completions = Some(CompletionSettingsContent { lsp_insert_mode: Some(LspInsertMode::Insert), words: Some(WordsCompletionMode::Disabled), ..CompletionSettingsContent::default() }); }); let mut cx = EditorTestContext::new(cx).await; let language_registry = cx.language_registry(); let language = rust_lang(); let completion_list = Arc::new(Mutex::new(None::)); let mut fake_servers = language_registry.register_fake_lsp( language.name(), FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions::default()), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new({ let completion_list = completion_list.clone(); move |server| { let completion_list = completion_list.clone(); server.set_request_handler::(move |_, _| { let completion_list = completion_list .lock() .clone() .expect("completion list should be set before requesting completions"); async move { Ok(Some(lsp::CompletionResponse::List(completion_list))) } }); } })), ..FakeLspAdapter::default() }, ); language_registry.add(language.clone()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let _language_server = fake_servers .next() .await .expect("language server should start"); cx.executor().run_until_parked(); for (lsp_insert_mode, expected_pair_state) in [ (LspInsertMode::ReplaceSuffix, "test(value1, value2=ˇvalue2)"), (LspInsertMode::Insert, "test(value1, value2=ˇvalue2)"), (LspInsertMode::Replace, "test(value1, value2=ˇ)"), (LspInsertMode::ReplaceSubsequence, "test(value1, value2=ˇ)"), ] { update_test_language_settings(&mut cx, &|settings| { settings.defaults.completions = Some(CompletionSettingsContent { lsp_insert_mode: Some(lsp_insert_mode), words: Some(WordsCompletionMode::Disabled), ..CompletionSettingsContent::default() }); }); for (use_default_range, use_insert_range) in [(false, true), (true, true), (false, false), (true, false)] { for filter_text in [Some("value2"), None] { eprintln!( "{lsp_insert_mode:?}, {use_default_range:?}, {use_insert_range:?}, {filter_text:?}" ); cx.set_state("test(value1, ˇvalue2)"); let insert = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 13)); let replace = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 19)); let text_edit = if use_insert_range { lsp::CompletionTextEdit::InsertAndReplace(lsp::InsertReplaceEdit { insert, replace, new_text: "value2=".to_string(), }) } else { lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: replace, new_text: "value2=".to_string(), }) }; let edit_range = if use_insert_range { lsp::CompletionListItemDefaultsEditRange::InsertAndReplace { insert, replace } } else { lsp::CompletionListItemDefaultsEditRange::Range(replace) }; *completion_list.lock() = Some(lsp::CompletionList { items: vec![lsp::CompletionItem { label: "value2=".to_string(), filter_text: filter_text.map(str::to_string), text_edit: (!use_default_range).then_some(text_edit), ..lsp::CompletionItem::default() }], item_defaults: use_default_range.then(|| lsp::CompletionListItemDefaults { edit_range: Some(edit_range), ..lsp::CompletionListItemDefaults::default() }), ..lsp::CompletionList::default() }); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, window, cx| { editor.confirm_completion(&ConfirmCompletion::default(), window, cx) }) .expect("completion should be accepted") .await .expect("completion should succeed"); cx.assert_editor_state(if use_insert_range { expected_pair_state } else { "test(value1, value2=ˇ)" }); } } } } #[gpui::test] async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; let initial_state = "SubˇError"; let buffer_marked_text = ""; let completion_text = "SubscriptionError"; let expected_with_insert_mode = "SubscriptionErrorˇError"; let expected_with_replace_mode = "SubscriptionErrorˇ"; update_test_language_settings(&mut cx, &|settings| { settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), // set the opposite here to ensure that the action is overriding the default behavior lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); let counter = Arc::new(AtomicUsize::new(0)); handle_completion_request_with_insert_and_replace( &mut cx, buffer_marked_text, vec![(completion_text, completion_text)], counter.clone(), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 1); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }); cx.assert_editor_state(expected_with_replace_mode); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); update_test_language_settings(&mut cx, &|settings| { settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), // set the opposite here to ensure that the action is overriding the default behavior lsp_insert_mode: Some(LspInsertMode::Replace), ..Default::default() }); }); cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, buffer_marked_text, vec![(completion_text, completion_text)], counter.clone(), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 2); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion_insert(&ConfirmCompletionInsert, window, cx) .unwrap() }); cx.assert_editor_state(expected_with_insert_mode); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); } #[gpui::test] async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; // scenario: surrounding text matches completion text let completion_text = "to_offset"; let initial_state = indoc! {" 1. buf.to_offˇsuffix 2. buf.to_offˇsuf 3. buf.to_offˇfix 4. buf.to_offˇ 5. into_offˇensive 6. ˇsuffix 7. let ˇ // 8. aaˇzz 9. buf.to_off«zzzzzˇ»suffix 10. buf.«ˇzzzzz»suffix 11. to_off«ˇzzzzz» buf.to_offˇsuffix // newest cursor "}; let completion_marked_buffer = indoc! {" 1. buf.to_offsuffix 2. buf.to_offsuf 3. buf.to_offfix 4. buf.to_off 5. into_offensive 6. suffix 7. let // 8. aazz 9. buf.to_offzzzzzsuffix 10. buf.zzzzzsuffix 11. to_offzzzzz buf. // newest cursor "}; let expected = indoc! {" 1. buf.to_offsetˇ 2. buf.to_offsetˇsuf 3. buf.to_offsetˇfix 4. buf.to_offsetˇ 5. into_offsetˇensive 6. to_offsetˇsuffix 7. let to_offsetˇ // 8. aato_offsetˇzz 9. buf.to_offsetˇ 10. buf.to_offsetˇsuffix 11. to_offsetˇ buf.to_offsetˇ // newest cursor "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, completion_marked_buffer, vec![(completion_text, completion_text)], Arc::new(AtomicUsize::new(0)), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }); cx.assert_editor_state(expected); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); // scenario: surrounding text matches surroundings of newest cursor, inserting at the end let completion_text = "foo_and_bar"; let initial_state = indoc! {" 1. ooanbˇ 2. zooanbˇ 3. ooanbˇz 4. zooanbˇz 5. ooanˇ 6. oanbˇ ooanbˇ "}; let completion_marked_buffer = indoc! {" 1. ooanb 2. zooanb 3. ooanbz 4. zooanbz 5. ooan 6. oanb "}; let expected = indoc! {" 1. foo_and_barˇ 2. zfoo_and_barˇ 3. foo_and_barˇz 4. zfoo_and_barˇz 5. ooanfoo_and_barˇ 6. oanbfoo_and_barˇ foo_and_barˇ "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, completion_marked_buffer, vec![(completion_text, completion_text)], Arc::new(AtomicUsize::new(0)), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }); cx.assert_editor_state(expected); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); // scenario: surrounding text matches surroundings of newest cursor, inserted at the middle // (expects the same as if it was inserted at the end) let completion_text = "foo_and_bar"; let initial_state = indoc! {" 1. ooˇanb 2. zooˇanb 3. ooˇanbz 4. zooˇanbz ooˇanb "}; let completion_marked_buffer = indoc! {" 1. ooanb 2. zooanb 3. ooanbz 4. zooanbz "}; let expected = indoc! {" 1. foo_and_barˇ 2. zfoo_and_barˇ 3. foo_and_barˇz 4. zfoo_and_barˇz foo_and_barˇ "}; cx.set_state(initial_state); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request_with_insert_and_replace( &mut cx, completion_marked_buffer, vec![(completion_text, completion_text)], Arc::new(AtomicUsize::new(0)), ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }); cx.assert_editor_state(expected); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); } // This used to crash #[gpui::test] async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_text = indoc! {" fn main() { 10.satu; // // separate1 // separate2 // separate3 // 10.satu20; } "}; let multibuffer_text_with_selections = indoc! {" fn main() { 10.satuˇ; // 10.satuˇ20; } "}; let expected_multibuffer = indoc! {" fn main() { 10.saturating_sub()ˇ; // 10.saturating_sub()ˇ; } "}; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": buffer_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: None, ..lsp::CompletionOptions::default() }), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [ Point::zero()..Point::new(2, 0), Point::new(7, 0)..buffer.read(cx).max_point(), ], 0, cx, ); multi_buffer }); let editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { Editor::new( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::Default, }, multi_buffer.clone(), Some(project.clone()), window, cx, ) }) }); let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); pane.update_in(cx, |pane, window, cx| { pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); }); let fake_server = fake_servers.next().await.unwrap(); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(1, 11)..Point::new(1, 11), Point::new(5, 11)..Point::new(5, 11), ]) }); assert_text_with_selections(editor, multibuffer_text_with_selections, cx); }); editor.update_in(cx, |editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); fake_server .set_request_handler::(move |_, _| async move { let completion_item = lsp::CompletionItem { label: "saturating_sub()".into(), text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( lsp::InsertReplaceEdit { new_text: "saturating_sub()".to_owned(), insert: lsp::Range::new( lsp::Position::new(9, 7), lsp::Position::new(9, 11), ), replace: lsp::Range::new( lsp::Position::new(9, 7), lsp::Position::new(9, 13), ), }, )), ..lsp::CompletionItem::default() }; Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) }) .next() .await .unwrap(); cx.condition(&editor, |editor, _| editor.context_menu_visible()) .await; editor .update_in(cx, |editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }) .await .unwrap(); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, expected_multibuffer, cx); }) } #[gpui::test] async fn test_completion_in_multibuffer_with_newest_selection_in_other_buffer( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let main_text = indoc! {" fn main() { 10.satu } "}; let other_text = indoc! {" const VALUE: u32 = 0; "}; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": main_text, "other.rs": other_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: None, ..lsp::CompletionOptions::default() }), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let main_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) }) .await .unwrap(); let other_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/other.rs"), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), main_buffer.clone(), [Point::zero()..main_buffer.read(cx).max_point()], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), other_buffer.clone(), [Point::zero()..other_buffer.read(cx).max_point()], 0, cx, ); multi_buffer }); let editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { Editor::new( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::Default, }, multi_buffer.clone(), Some(project.clone()), window, cx, ) }) }); let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); pane.update_in(cx, |pane, window, cx| { pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); }); let fake_server = fake_servers.next().await.unwrap(); cx.run_until_parked(); // Place the (only, and therefore newest) selection in `main.rs`, right after `satu`. let main_cursor = editor.update(cx, |editor, cx| { let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); let main_snapshot = main_buffer.read(cx).snapshot(); let anchor = main_snapshot.anchor_before(Point::new(1, 11)); multibuffer_snapshot .buffer_anchor_range_to_anchor_range(anchor..anchor) .expect("main.rs cursor position should map into the multibuffer") }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_anchor_ranges([main_cursor.clone()]); }); }); editor.update_in(cx, |editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); fake_server .set_request_handler::(move |_, _| async move { let completion_item = lsp::CompletionItem { label: "saturating_sub()".into(), text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( lsp::InsertReplaceEdit { new_text: "saturating_sub()".to_owned(), insert: lsp::Range::new( lsp::Position::new(1, 7), lsp::Position::new(1, 11), ), replace: lsp::Range::new( lsp::Position::new(1, 7), lsp::Position::new(1, 11), ), }, )), ..lsp::CompletionItem::default() }; Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) }) .next() .await .unwrap(); cx.condition(&editor, |editor, _| editor.context_menu_visible()) .await; // Move the newest selection into `other.rs` while keeping the completions menu // open. let other_cursor = editor.update(cx, |editor, cx| { let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); let other_snapshot = other_buffer.read(cx).snapshot(); let anchor = other_snapshot.anchor_before(Point::new(0, 6)); multibuffer_snapshot .buffer_anchor_range_to_anchor_range(anchor..anchor) .expect("other.rs cursor position should map into the multibuffer") }); editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::no_scroll().completions(false), window, cx, |s| s.select_anchor_ranges([other_cursor.clone()]), ); assert!( editor.context_menu_visible(), "the completions menu should still be open after moving the cursor" ); }); // This used to panic with `invalid anchor - buffer id does not match`. editor .update_in(cx, |editor, window, cx| { editor .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }) .await .unwrap(); // The completion targets `main.rs`, so it should still be applied there. main_buffer.read_with(cx, |buffer, _| { assert_eq!( buffer.text(), indoc! {" fn main() { 10.saturating_sub() } "} ); }); } #[gpui::test] async fn test_completion_filter_text_need_not_be_label_substring(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions::default()), ..Default::default() }, cx, ) .await; cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "rendered value".to_string(), filter_text: Some("typed_prefix".to_string()), ..Default::default() }, lsp::CompletionItem { label: "typed_prefix fallback".to_string(), ..Default::default() }, ]))) }); cx.set_state("fn main() { typed_prefixˇ }"); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { let context_menu = editor.context_menu.borrow(); let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref() else { panic!("expected completion menu to be open"); }; let completions = menu.completions.borrow(); let entries = menu.entries.borrow(); let completion = entries .iter() .filter_map(|entry| entry.as_match()) .map(|string_match| &completions[string_match.candidate_id]) .find(|completion| completion.label.text() == "rendered value") .expect("completion should be matched using its non-substring filterText"); assert_eq!(completion.label.filter_text(), "rendered value"); assert_eq!(completion.filter_text(), "typed_prefix"); }); } #[gpui::test] async fn test_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut 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() }), signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..Default::default() }, cx, ) .await; let counter = Arc::new(AtomicUsize::new(0)); cx.set_state(indoc! {" oneˇ two three "}); cx.simulate_keystroke("."); handle_completion_request( indoc! {" one.|<> two three "}, vec!["first_completion", "second_completion"], true, counter.clone(), &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 1); let _handler = handle_signature_help_request( &mut cx, lsp::SignatureHelp { signatures: vec![lsp::SignatureInformation { label: "test signature".to_string(), documentation: None, parameters: Some(vec![lsp::ParameterInformation { label: lsp::ParameterLabel::Simple("foo: u8".to_string()), documentation: None, }]), active_parameter: None, }], active_signature: None, active_parameter: None, }, ); cx.update_editor(|editor, window, cx| { assert!( !editor.signature_help_state.is_shown(), "No signature help was called for" ); editor.show_signature_help(&ShowSignatureHelp, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, _, _| { assert!( !editor.signature_help_state.is_shown(), "No signature help should be shown when completions menu is open" ); }); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor.context_menu_next(&Default::default(), window, cx); editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state(indoc! {" one.second_completionˇ two three "}); handle_resolve_completion_request( &mut cx, Some(vec![ ( //This overlaps with the primary completion edit which is //misbehavior from the LSP spec, test that we filter it out indoc! {" one.second_ˇcompletion two threeˇ "}, "overlapping additional edit", ), ( indoc! {" one.second_completion two threeˇ "}, "\nadditional edit", ), ]), ) .await; apply_additional_edits.await.unwrap(); cx.assert_editor_state(indoc! {" one.second_completionˇ two three additional edit "}); cx.set_state(indoc! {" one.second_completion twoˇ threeˇ additional edit "}); cx.simulate_keystroke(" "); assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); cx.simulate_keystroke("s"); assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); cx.assert_editor_state(indoc! {" one.second_completion two sˇ three sˇ additional edit "}); handle_completion_request( indoc! {" one.second_completion two s three additional edit "}, vec!["fourth_completion", "fifth_completion", "sixth_completion"], true, counter.clone(), &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 2); cx.simulate_keystroke("i"); handle_completion_request( indoc! {" one.second_completion two si three additional edit "}, vec!["fourth_completion", "fifth_completion", "sixth_completion"], true, counter.clone(), &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 3); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state(indoc! {" one.second_completion two sixth_completionˇ three sixth_completionˇ additional edit "}); apply_additional_edits.await.unwrap(); update_test_language_settings(&mut cx, &|settings| { settings.defaults.show_completions_on_input = Some(false); }); cx.set_state("editorˇ"); cx.simulate_keystroke("."); assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); cx.simulate_keystrokes("c l o"); cx.assert_editor_state("editor.cloˇ"); assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); handle_completion_request( "editor.", vec!["close", "clobber"], true, counter.clone(), &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 4); let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state("editor.clobberˇ"); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); } #[gpui::test] async fn test_completion_can_run_commands(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let command_calls = Arc::new(AtomicUsize::new(0)); let registered_command = "_the/command"; let closure_command_calls = command_calls.clone(); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), ..lsp::CompletionOptions::default() }), execute_command_provider: Some(lsp::ExecuteCommandOptions { commands: vec![registered_command.to_owned()], ..lsp::ExecuteCommandOptions::default() }), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |params, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "registered_command".to_owned(), text_edit: gen_text_edit(¶ms, ""), command: Some(lsp::Command { title: registered_command.to_owned(), command: "_the/command".to_owned(), arguments: Some(vec![serde_json::Value::Bool(true)]), }), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "unregistered_command".to_owned(), text_edit: gen_text_edit(¶ms, ""), command: Some(lsp::Command { title: "????????????".to_owned(), command: "????????????".to_owned(), arguments: Some(vec![serde_json::Value::Null]), }), ..lsp::CompletionItem::default() }, ]))) }, ); fake_server.set_request_handler::({ let command_calls = closure_command_calls.clone(); move |params, _| { assert_eq!(params.command, registered_command); let command_calls = command_calls.clone(); async move { command_calls.fetch_add(1, atomic::Ordering::Release); Ok(Some(json!(null))) } } }); })), ..FakeLspAdapter::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/a/main.rs")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); let _fake_server = fake_servers.next().await.unwrap(); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { cx.focus_self(window); editor.move_to_end(&MoveToEnd, window, cx); editor.handle_input(".", window, cx); }); cx.run_until_parked(); editor.update(cx, |editor, _| { assert!(editor.context_menu_visible()); if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { let completion_labels = menu .completions .borrow() .iter() .map(|c| c.label.text.clone()) .collect::>(); assert_eq!( completion_labels, &["registered_command", "unregistered_command",], ); } else { panic!("expected completion menu to be open"); } }); editor .update_in(cx, |editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }) .await .unwrap(); cx.run_until_parked(); assert_eq!( command_calls.load(atomic::Ordering::Acquire), 1, "For completion with a registered command, Zed should send a command execution request", ); editor.update_in(cx, |editor, window, cx| { cx.focus_self(window); editor.handle_input(".", window, cx); }); cx.run_until_parked(); editor.update(cx, |editor, _| { assert!(editor.context_menu_visible()); if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { let completion_labels = menu .completions .borrow() .iter() .map(|c| c.label.text.clone()) .collect::>(); assert_eq!( completion_labels, &["registered_command", "unregistered_command",], ); } else { panic!("expected completion menu to be open"); } }); editor .update_in(cx, |editor, window, cx| { editor.context_menu_next(&Default::default(), window, cx); editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }) .await .unwrap(); cx.run_until_parked(); assert_eq!( command_calls.load(atomic::Ordering::Acquire), 1, "For completion with an unregistered command, Zed should not send a command execution request", ); } #[gpui::test] async fn test_completion_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), ..Default::default() }), ..Default::default() }, cx, ) .await; let counter = Arc::new(AtomicUsize::new(0)); cx.set_state("objˇ"); cx.simulate_keystroke("."); // Initial completion request returns complete results let is_incomplete = false; handle_completion_request( "obj.|<>", vec!["a", "ab", "abc"], is_incomplete, counter.clone(), &mut cx, ) .await; cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.ˇ"); check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); // Type "a" - filters existing completions cx.simulate_keystroke("a"); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.aˇ"); check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); // Type "b" - filters existing completions cx.simulate_keystroke("b"); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.abˇ"); check_displayed_completions(vec!["ab", "abc"], &mut cx); // Type "c" - filters existing completions cx.simulate_keystroke("c"); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.abcˇ"); check_displayed_completions(vec!["abc"], &mut cx); // Backspace to delete "c" - filters existing completions cx.update_editor(|editor, window, cx| { editor.backspace(&Backspace, window, cx); }); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.abˇ"); check_displayed_completions(vec!["ab", "abc"], &mut cx); // Moving cursor to the left dismisses menu. cx.update_editor(|editor, window, cx| { editor.move_left(&MoveLeft, window, cx); }); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.assert_editor_state("obj.aˇb"); cx.update_editor(|editor, _, _| { assert_eq!(editor.context_menu_visible(), false); }); // Type "b" - new request cx.simulate_keystroke("b"); let is_incomplete = false; handle_completion_request( "obj.a", vec!["ab", "abc"], is_incomplete, counter.clone(), &mut cx, ) .await; cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 2); cx.assert_editor_state("obj.abˇb"); check_displayed_completions(vec!["ab", "abc"], &mut cx); // Backspace to delete "b" - since query was "ab" and is now "a", new request is made. cx.update_editor(|editor, window, cx| { editor.backspace(&Backspace, window, cx); }); let is_incomplete = false; handle_completion_request( "obj.b", vec!["a", "ab", "abc"], is_incomplete, counter.clone(), &mut cx, ) .await; cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 3); cx.assert_editor_state("obj.aˇb"); check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); // Backspace to delete "a" - dismisses menu. cx.update_editor(|editor, window, cx| { editor.backspace(&Backspace, window, cx); }); cx.run_until_parked(); assert_eq!(counter.load(atomic::Ordering::Acquire), 3); cx.assert_editor_state("obj.ˇb"); cx.update_editor(|editor, _, _| { assert_eq!(editor.context_menu_visible(), false); }); } #[gpui::test] async fn test_word_completion(cx: &mut TestAppContext) { let lsp_fetch_timeout_ms = 10; init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words_min_length: Some(0), lsp_fetch_timeout_ms: Some(10), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), ..lsp::CompletionOptions::default() }), signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let throttle_completions = Arc::new(AtomicBool::new(false)); let lsp_throttle_completions = throttle_completions.clone(); let _completion_requests_handler = cx.lsp .server .on_request::(move |_, cx| { let lsp_throttle_completions = lsp_throttle_completions.clone(); let cx = cx.clone(); async move { if lsp_throttle_completions.load(atomic::Ordering::Acquire) { cx.background_executor() .timer(Duration::from_millis(lsp_fetch_timeout_ms * 10)) .await; } Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "first".into(), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "last".into(), ..lsp::CompletionItem::default() }, ]))) } }); cx.set_state(indoc! {" oneˇ two three "}); cx.simulate_keystroke("."); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, window, cx| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["first", "last"], "When LSP server is fast to reply, no fallback word completions are used" ); } else { panic!("expected completion menu to be open"); } editor.cancel(&Cancel, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| !editor.context_menu_visible()) .await; throttle_completions.store(true, atomic::Ordering::Release); cx.simulate_keystroke("."); cx.executor() .advance_clock(Duration::from_millis(lsp_fetch_timeout_ms * 2)); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["one", "three", "two"], "When LSP server is slow, document words can be shown instead, if configured accordingly"); } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Enabled), words_min_length: Some(0), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), ..lsp::CompletionOptions::default() }), signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let _completion_requests_handler = cx.lsp .server .on_request::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "first".into(), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "last".into(), ..lsp::CompletionItem::default() }, ]))) }); cx.set_state(indoc! {"ˇ first last second "}); cx.simulate_keystroke("."); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["first", "last", "second"], "Word completions that has the same edit as the any of the LSP ones, should not be proposed" ); } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), ..lsp::CompletionOptions::default() }), signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let _completion_requests_handler = cx.lsp .server .on_request::(move |_, _| async move { panic!("LSP completions should not be queried when dealing with word completions") }); cx.set_state(indoc! {"ˇ first last second "}); cx.update_editor(|editor, window, cx| { editor.show_word_completions(&ShowWordCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["first", "last", "second"], "`ShowWordCompletions` action should show word completions" ); } else { panic!("expected completion menu to be open"); } }); cx.simulate_keystroke("l"); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["last"], "After showing word completions, further editing should filter them and not query the LSP" ); } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_completions_use_selection_head(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions::default()), ..lsp::ServerCapabilities::default() }, cx, ) .await; let _completion_requests_handler = cx.lsp .server .on_request::(move |_, _| async move { panic!("LSP completions should not be queried when dealing with word completions") }); cx.set_state(indoc! {"«applˇ» applepie banana cherry "}); cx.update_editor(|editor, window, cx| { editor.show_word_completions(&ShowWordCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["applepie"], "Completion query should use the selection head (`appl`), filtering to words with that prefix" ); } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words_min_length: Some(0), lsp: Some(false), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; cx.set_state(indoc! {"ˇ 0_usize let 33 4.5f32 "}); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, window, cx| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["let"], "With no digits in the completion query, no digits should be in the word completions" ); } else { panic!("expected completion menu to be open"); } editor.cancel(&Cancel, window, cx); }); cx.set_state(indoc! {"3ˇ 0_usize let 3 33.35f32 "}); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["33", "35f32"], "The digit is in the completion query, \ return matching words with digits (`33`, `35f32`) but exclude query duplicates (`3`)"); } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_word_completions_do_not_show_before_threshold(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Enabled), words_min_length: Some(3), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; cx.set_state(indoc! {"ˇ wow wowen wowser "}); cx.simulate_keystroke("w"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!( "expected completion menu to be hidden, as words completion threshold is not met" ); } }); cx.update_editor(|editor, window, cx| { editor.show_word_completions(&ShowWordCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["wowser", "wowen", "wow"], "Even though the threshold is not met, invoking word completions with an action should provide the completions"); } else { panic!("expected completion menu to be open after the word completions are called with an action"); } editor.cancel(&Cancel, window, cx); }); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!("expected completion menu to be hidden after canceling"); } }); cx.simulate_keystroke("o"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!( "expected completion menu to be hidden, as words completion threshold is not met still" ); } }); cx.simulate_keystroke("w"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["wowen", "wowser"], "After word completion threshold is met, matching words should be shown, excluding the already typed word"); } else { panic!("expected completion menu to be open after the word completions threshold is met"); } }); } #[gpui::test] async fn test_word_completions_disabled(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Enabled), words_min_length: Some(0), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; cx.update_editor(|editor, _, _| { editor.disable_word_completions(); }); cx.set_state(indoc! {"ˇ wow wowen wowser "}); cx.simulate_keystroke("w"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!( "expected completion menu to be hidden, as words completion are disabled for this editor" ); } }); cx.update_editor(|editor, window, cx| { editor.show_word_completions(&ShowWordCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!( "expected completion menu to be hidden even if called for explicitly, as words completion are disabled for this editor" ); } }); } #[gpui::test] async fn test_word_completions_disabled_with_no_provider(cx: &mut TestAppContext) { init_test(cx, |language_settings| { language_settings.defaults.completions = Some(CompletionSettingsContent { words: Some(WordsCompletionMode::Disabled), words_min_length: Some(0), lsp_insert_mode: Some(LspInsertMode::Insert), ..Default::default() }); }); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; cx.update_editor(|editor, _, _| { editor.set_completion_provider(None); }); cx.set_state(indoc! {"ˇ wow wowen wowser "}); cx.simulate_keystroke("w"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if editor.context_menu.borrow_mut().is_some() { panic!("expected completion menu to be hidden, as disabled in settings"); } }); } fn gen_text_edit(params: &CompletionParams, text: &str) -> Option { let position = || lsp::Position { line: params.text_document_position.position.line, character: params.text_document_position.position.character, }; Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: position(), end: position(), }, new_text: text.to_string(), })) } #[gpui::test] async fn test_multiline_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let typescript_language = Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..LanguageMatcher::default() }) .into(), line_comments: vec!["// ".into()], ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), )); language_registry.add(typescript_language.clone()); let mut fake_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), ..lsp::CompletionOptions::default() }), signature_help_provider: Some(lsp::SignatureHelpOptions::default()), ..lsp::ServerCapabilities::default() }, // Emulate vtsls label generation label_for_completion: Some(Box::new(|item, _| { let text = if let Some(description) = item .label_details .as_ref() .and_then(|label_details| label_details.description.as_ref()) { format!("{} {}", item.label, description) } else if let Some(detail) = &item.detail { format!("{} {}", item.label, detail) } else { item.label.clone() }; Some(language::CodeLabel::plain(text, None)) })), ..FakeLspAdapter::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let _buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/a/main.ts"), cx) }) .await .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.ts")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.run_until_parked(); let multiline_label = "StickyHeaderExcerpt {\n excerpt,\n next_excerpt_controls_present,\n next_buffer_row,\n }: StickyHeaderExcerpt<'_>,"; let multiline_label_2 = "a\nb\nc\n"; let multiline_detail = "[]struct {\n\tSignerId\tstruct {\n\t\tIssuer\t\t\tstring\t`json:\"issuer\"`\n\t\tSubjectSerialNumber\"`\n}}"; let multiline_description = "d\ne\nf\n"; let multiline_detail_2 = "g\nh\ni\n"; let mut completion_handle = fake_server.set_request_handler::( move |params, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: multiline_label.to_string(), text_edit: gen_text_edit(¶ms, "new_text_1"), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "single line label 1".to_string(), detail: Some(multiline_detail.to_string()), text_edit: gen_text_edit(¶ms, "new_text_2"), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "single line label 2".to_string(), label_details: Some(lsp::CompletionItemLabelDetails { description: Some(multiline_description.to_string()), detail: None, }), text_edit: gen_text_edit(¶ms, "new_text_2"), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: multiline_label_2.to_string(), detail: Some(multiline_detail_2.to_string()), text_edit: gen_text_edit(¶ms, "new_text_3"), ..lsp::CompletionItem::default() }, lsp::CompletionItem { label: "Label with many spaces and \t but without newlines".to_string(), detail: Some( "Details with many spaces and \t but without newlines".to_string(), ), text_edit: gen_text_edit(¶ms, "new_text_4"), ..lsp::CompletionItem::default() }, ]))) }, ); editor.update_in(cx, |editor, window, cx| { cx.focus_self(window); editor.move_to_end(&MoveToEnd, window, cx); editor.handle_input(".", window, cx); }); cx.run_until_parked(); completion_handle.next().await.unwrap(); editor.update(cx, |editor, _| { assert!(editor.context_menu_visible()); if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { let completion_labels = menu .completions .borrow() .iter() .map(|c| c.label.text.clone()) .collect::>(); assert_eq!( completion_labels, &[ "StickyHeaderExcerpt { excerpt, next_excerpt_controls_present, next_buffer_row, }: StickyHeaderExcerpt<'_>,", "single line label 1 []struct { SignerId struct { Issuer string `json:\"issuer\"` SubjectSerialNumber\"` }}", "single line label 2 d e f ", "a b c g h i ", "Label with many spaces and \t but without newlines Details with many spaces and \t but without newlines", ], "Completion items should have their labels without newlines, also replacing excessive whitespaces. Completion items without newlines should not be altered.", ); for completion in menu .completions .borrow() .iter() { assert_eq!( completion.label.filter_range, 0..completion.label.text.len(), "Adjusted completion items should still keep their filter ranges for the entire label. Item: {completion:?}" ); } } else { panic!("expected completion menu to be open"); } }); } #[gpui::test] async fn test_completion_page_up_down_keys(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "first".into(), ..Default::default() }, lsp::CompletionItem { label: "last".into(), ..Default::default() }, ]))) }); cx.set_state("variableˇ"); cx.simulate_keystroke("."); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["first", "last"]); } else { panic!("expected completion menu to be open"); } }); cx.update_editor(|editor, window, cx| { editor.move_page_down(&MovePageDown::default(), window, cx); if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert!( menu.selected_item == 1, "expected PageDown to select the last item from the context menu" ); } else { panic!("expected completion menu to stay open after PageDown"); } }); cx.update_editor(|editor, window, cx| { editor.move_page_up(&MovePageUp::default(), window, cx); if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert!( menu.selected_item == 0, "expected PageUp to select the first item from the context menu" ); } else { panic!("expected completion menu to stay open after PageUp"); } }); } #[gpui::test] async fn test_as_is_completions(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "unsafe".into(), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 1, character: 2, }, end: lsp::Position { line: 1, character: 3, }, }, new_text: "unsafe".to_string(), })), insert_text_mode: Some(lsp::InsertTextMode::AS_IS), ..Default::default() }, ]))) }); cx.set_state("fn a() {}\n nˇ"); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.trigger_completion_on_input("n", true, window, cx) }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.confirm_completion(&Default::default(), window, cx) }); cx.executor().run_until_parked(); cx.assert_editor_state("fn a() {}\n unsafeˇ"); } #[gpui::test] async fn test_panic_during_c_completions(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::try_unwrap(languages::language("c", tree_sitter_c::LANGUAGE.into())).unwrap(); let mut cx = EditorLspTestContext::new( language, lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { ..lsp::CompletionOptions::default() }), ..lsp::ServerCapabilities::default() }, cx, ) .await; cx.set_state( "#ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H ˇ", ); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.handle_input("#", window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.handle_input("i", window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.handle_input("n", window, cx); }); cx.executor().run_until_parked(); cx.assert_editor_state( "#ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H #inˇ", ); cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: false, item_defaults: None, items: vec![lsp::CompletionItem { kind: Some(lsp::CompletionItemKind::SNIPPET), label_details: Some(lsp::CompletionItemLabelDetails { detail: Some("header".to_string()), description: None, }), label: " include".to_string(), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 8, character: 1, }, end: lsp::Position { line: 8, character: 1, }, }, new_text: "include \"$0\"".to_string(), })), sort_text: Some("40b67681include".to_string()), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), filter_text: Some("include".to_string()), insert_text: Some("include \"$0\"".to_string()), ..lsp::CompletionItem::default() }], }))) }); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.confirm_completion(&ConfirmCompletion::default(), window, cx) }); cx.executor().run_until_parked(); cx.assert_editor_state( "#ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H #include \"ˇ\"", ); cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: true, item_defaults: None, items: vec![lsp::CompletionItem { kind: Some(lsp::CompletionItemKind::FILE), label: "AGL/".to_string(), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 8, character: 10, }, end: lsp::Position { line: 8, character: 11, }, }, new_text: "AGL/".to_string(), })), sort_text: Some("40b67681AGL/".to_string()), insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), filter_text: Some("AGL/".to_string()), insert_text: Some("AGL/".to_string()), ..lsp::CompletionItem::default() }], }))) }); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.confirm_completion(&ConfirmCompletion::default(), window, cx) }); cx.executor().run_until_parked(); cx.assert_editor_state( r##"#ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H #include "AGL/ˇ"##, ); cx.update_editor(|editor, window, cx| { editor.handle_input("\"", window, cx); }); cx.executor().run_until_parked(); cx.assert_editor_state( r##"#ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H #include "AGL/"ˇ"##, ); } #[gpui::test] async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(false), ..lsp::CompletionOptions::default() }), ..lsp::ServerCapabilities::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let completion_item = lsp::CompletionItem { label: "Some".into(), kind: Some(lsp::CompletionItemKind::SNIPPET), detail: Some("Wrap the expression in an `Option::Some`".to_string()), documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "```rust\nSome(2)\n```".to_string(), })), deprecated: Some(false), sort_text: Some("Some".to_string()), filter_text: Some("Some".to_string()), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 22, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "Some(2)".to_string(), })), additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 20, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "".to_string(), }]), ..Default::default() }; let closure_completion_item = completion_item.clone(); let counter = Arc::new(AtomicUsize::new(0)); let counter_clone = counter.clone(); let mut request = cx.set_request_handler::(move |_, _, _| { let task_completion_item = closure_completion_item.clone(); counter_clone.fetch_add(1, atomic::Ordering::Release); async move { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: true, item_defaults: None, items: vec![task_completion_item], }))) } }); cx.executor().run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.assert_editor_state("fn main() { let a = 2.ˇ; }"); assert!(request.next().await.is_some()); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); cx.simulate_keystrokes("S o m"); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.assert_editor_state("fn main() { let a = 2.Somˇ; }"); assert!(request.next().await.is_some()); assert!(request.next().await.is_some()); assert!(request.next().await.is_some()); request.close(); assert!(request.next().await.is_none()); assert_eq!( counter.load(atomic::Ordering::Acquire), 4, "With the completions menu open, only one LSP request should happen per input" ); } #[gpui::test] async fn test_toggle_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into(), "//! ".into(), "/// ".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // If multiple selections intersect a line, the line is only toggled once. cx.set_state(indoc! {" fn a() { «//b(); ˇ»// «c(); //ˇ» d(); } "}); cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { «b(); ˇ»«c(); ˇ» d(); } "}); // The comment prefix is inserted at the same column for every line in a // selection. cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { // «b(); ˇ»// «c(); ˇ» // d(); } "}); // If a selection ends at the beginning of a line, that line is not toggled. cx.set_selections_state(indoc! {" fn a() { // b(); «// c(); ˇ» // d(); } "}); cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { // b(); «c(); ˇ» // d(); } "}); // If a selection span a single line and is empty, the line is toggled. cx.set_state(indoc! {" fn a() { a(); b(); ˇ } "}); cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { a(); b(); //•ˇ } "}); // If a selection spans multiple lines, empty lines are also toggled. cx.set_state(indoc! {" fn a() { «a(); c();ˇ» } "}); cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { // «a(); //• // c();ˇ» } "}); // If a selection includes multiple comment prefixes, all lines are uncommented. cx.set_state(indoc! {" fn a() { «// a(); /// b(); //! c();ˇ» } "}); cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); cx.assert_editor_state(indoc! {" fn a() { «a(); b(); c();ˇ» } "}); } #[gpui::test] async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into(), "//! ".into(), "/// ".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let toggle_comments = &ToggleComments { advance_downwards: false, ignore_indent: true, comment_empty_lines: false, }; // If multiple selections intersect a line, the line is only toggled once. cx.set_state(indoc! {" fn a() { // «b(); // c(); // ˇ» d(); } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { «b(); c(); ˇ» d(); } "}); // The comment prefix is inserted at the beginning of each line cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { // «b(); // c(); // ˇ» d(); } "}); // If a selection ends at the beginning of a line, that line is not toggled. cx.set_selections_state(indoc! {" fn a() { // b(); // «c(); ˇ»// d(); } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { // b(); «c(); ˇ»// d(); } "}); // If a selection span a single line and is empty, the line is toggled. cx.set_state(indoc! {" fn a() { a(); b(); ˇ } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { a(); b(); //ˇ } "}); // If a selection span multiple lines, empty lines are not toggled. cx.set_state(indoc! {" fn a() { «a(); c();ˇ» } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { // «a(); // c();ˇ» } "}); // If a selection includes multiple comment prefixes, all lines are uncommented. cx.set_state(indoc! {" fn a() { // «a(); /// b(); //! c();ˇ» } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { «a(); b(); c();ˇ» } "}); } #[gpui::test] async fn test_toggle_comment_commenting_blank_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // A prefix without a trailing space keeps every expectation below free of // trailing whitespace, which would otherwise be stripped on save and break // the assertions on commented blank lines. let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["//".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let toggle_comments = &ToggleComments::default(); cx.set_state(indoc! {" «fn a() { b(); c(); }ˇ» "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" //«fn a() { // b(); // // c(); //}ˇ» "}); // Toggling again removes the prefix from every line, blank ones included. cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" «fn a() { b(); c(); }ˇ» "}); // All prefixes in a block go at one shared column so they line up: the // smallest indent among the rows being commented. A blank line contributes // 0 and drags that to 0 - shifting every line in the block left. VS Code // does the same. cx.set_state(indoc! {" fn a() { «b(); c();ˇ» } "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { // «b(); // // c();ˇ» } "}); } #[gpui::test] async fn test_toggle_comment_uncomments_after_parameter_change(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["//".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let skip_blank_lines = &ToggleComments { comment_empty_lines: false, ..Default::default() }; let comment_blank_lines = &ToggleComments::default(); cx.set_state(indoc! {" «fn a() { b(); c(); }ˇ» "}); // Comment with one binding: the blank line gets no marker. cx.update_editor(|e, window, cx| e.toggle_comments(skip_blank_lines, window, cx)); cx.assert_editor_state(indoc! {" //«fn a() { // b(); // c(); //}ˇ» "}); // Toggle with the other binding. The blank line still has no marker, but it must // not make the block look uncommented: the markers are removed, not doubled. cx.update_editor(|e, window, cx| e.toggle_comments(comment_blank_lines, window, cx)); cx.assert_editor_state(indoc! {" «fn a() { b(); c(); }ˇ» "}); } #[gpui::test] async fn test_toggle_comment_selection_of_only_blank_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["//".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); let toggle_comments = &ToggleComments::default(); // No line carries a marker, so there is nothing to remove: comment them. cx.set_state(indoc! {" fn a() { « ˇ»} "}); cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); cx.assert_editor_state(indoc! {" fn a() { //« // ˇ»} "}); } #[gpui::test] async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["// ".into()], ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), )); let mut cx = EditorTestContext::new(cx).await; cx.language_registry().add(language.clone()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); }); let toggle_comments = &ToggleComments { advance_downwards: true, ignore_indent: false, comment_empty_lines: false, }; // Single cursor on one line -> advance // Cursor moves horizontally 3 characters as well on non-blank line cx.set_state(indoc!( "fn a() { ˇdog(); cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // dog(); catˇ(); }" )); // Single selection on one line -> don't advance cx.set_state(indoc!( "fn a() { «dog()ˇ»; cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // «dog()ˇ»; cat(); }" )); // Multiple cursors on one line -> advance cx.set_state(indoc!( "fn a() { ˇdˇog(); cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // dog(); catˇ(ˇ); }" )); // Multiple cursors on one line, with selection -> don't advance cx.set_state(indoc!( "fn a() { ˇdˇog«()ˇ»; cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // ˇdˇog«()ˇ»; cat(); }" )); // Single cursor on one line -> advance // Cursor moves to column 0 on blank line cx.set_state(indoc!( "fn a() { ˇdog(); cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // dog(); ˇ cat(); }" )); // Single cursor on one line -> advance // Cursor starts and ends at column 0 cx.set_state(indoc!( "fn a() { ˇ dog(); cat(); }" )); cx.update_editor(|editor, window, cx| { editor.toggle_comments(toggle_comments, window, cx); }); cx.assert_editor_state(indoc!( "fn a() { // dog(); ˇ cat(); }" )); } #[gpui::test] async fn test_toggle_block_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let html_language = Arc::new( Language::new( LanguageConfig { name: "HTML".into(), block_comment: Some(BlockCommentConfig { start: "".into(), tab_size: 0, }), ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), ) .with_injection_query( r#" (script_element (raw_text) @injection.content (#set! injection.language "javascript")) "#, ) .unwrap(), ); let javascript_language = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), line_comments: vec!["// ".into()], ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )); cx.language_registry().add(html_language.clone()); cx.language_registry().add(javascript_language); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(html_language), cx); }); // Toggle comments for empty selections cx.set_state( &r#"

A

ˇ

B

ˇ

C

ˇ "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.toggle_comments(&ToggleComments::default(), window, cx) }); cx.assert_editor_state( &r#" "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.toggle_comments(&ToggleComments::default(), window, cx) }); cx.assert_editor_state( &r#"

A

ˇ

B

ˇ

C

ˇ "# .unindent(), ); // Toggle comments for mixture of empty and non-empty selections, where // multiple selections occupy a given line. cx.set_state( &r#"

A«

ˇ»B

ˇ

C«

ˇ»D

ˇ "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.toggle_comments(&ToggleComments::default(), window, cx) }); cx.assert_editor_state( &r#" "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.toggle_comments(&ToggleComments::default(), window, cx) }); cx.assert_editor_state( &r#"

A«

ˇ»B

ˇ

C«

ˇ»D

ˇ "# .unindent(), ); // Toggle comments when different languages are active for different // selections. cx.set_state( &r#" ˇ "# .unindent(), ); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.toggle_comments(&ToggleComments::default(), window, cx) }); // TODO this is how it actually worked in Zed Stable, which is not very ergonomic. // Uncommenting and commenting from this position brings in even more wrong artifacts. cx.assert_editor_state( &r#" // ˇvar x = new Y(); "# .unindent(), ); } #[gpui::test] fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer = cx.new(|cx| Buffer::local(sample_text(6, 4, 'a'), cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [ Point::new(0, 0)..Point::new(0, 4), Point::new(5, 0)..Point::new(5, 4), ], 0, cx, ); assert_eq!(multibuffer.read(cx).text(), "aaaa\nffff"); multibuffer }); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); editor.update_in(cx, |editor, window, cx| { assert_eq!(editor.text(cx), "aaaa\nffff"); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 0)..Point::new(0, 0), Point::new(1, 0)..Point::new(1, 0), ]) }); editor.handle_input("X", window, cx); assert_eq!(editor.text(cx), "Xaaaa\nXffff"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), ] ); // Ensure the cursor's head is respected when deleting across an excerpt boundary. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 2)..Point::new(1, 2)]) }); editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "Xa\nfff"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(1, 0)..Point::new(1, 0)] ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 1)..Point::new(0, 1)]) }); editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "X\nff"); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 1)..Point::new(0, 1)] ); }); } #[gpui::test] fn test_header_jump_data_uses_selection_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); // 25-line buffer so excerpts at rows 1, 10, and 20 (each a 1-line range, // expanded by 2 context lines) can't merge into a single excerpt. let buffer_text = (0..25) .map(|row| format!("line {row}")) .collect::>() .join("\n"); let buffer = cx.new(|cx| Buffer::local(buffer_text, cx)); let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [ Point::new(1, 0)..Point::new(1, 0), Point::new(10, 0)..Point::new(10, 0), Point::new(20, 0)..Point::new(20, 0), ], 2, cx, ); multibuffer }); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); let display_snapshot = editor.display_snapshot(cx); // Ensure the three ranges landed in three separate excerpts. let excerpts: Vec<_> = snapshot .buffer_snapshot() .excerpts_for_buffer(buffer_id) .collect(); assert_eq!(excerpts.len(), 3); // Place the cursor at the start of the third excerpt, expressed in // terms of the underlying buffer. let selection_buffer_row = 20; let buffer_entity = editor.buffer().read(cx).buffer(buffer_id).unwrap(); let selection_anchor = editor.buffer().update(cx, |multibuffer, cx| { multibuffer .buffer_point_to_anchor(&buffer_entity, Point::new(selection_buffer_row, 0), cx) .expect("buffer row 20 maps to a multibuffer anchor") }); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_anchor_ranges([selection_anchor..selection_anchor]) }); let mut latest_selection_anchors: HashMap = HashMap::default(); for selection in editor.selections.all_anchors(&display_snapshot).iter() { let head = selection.head(); if let Some((text_anchor, _)) = snapshot.buffer_snapshot().anchor_to_buffer_anchor(head) { latest_selection_anchors.insert(text_anchor.buffer_id, head); } } // The sticky buffer header represents the FIRST excerpt of its buffer, // even when the cursor is in a later excerpt. That mismatch is the // precondition for the regression. let first_excerpt = snapshot .buffer_snapshot() .excerpt_boundaries_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) .next() .expect("multibuffer has at least one excerpt") .next; let jump_data = header_jump_data( &snapshot, DisplayRow(0), FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, &first_excerpt, &latest_selection_anchors, ); match jump_data { JumpData::MultiBufferPoint { position, line_offset_from_top, .. } => { assert_eq!( position.row, selection_buffer_row, "jump should target the cursor's buffer row, not the first excerpt's row" ); assert!( line_offset_from_top < selection_buffer_row, "line_offset_from_top ({line_offset_from_top}) should be measured from the \ selection's excerpt, not the first excerpt; expected less than \ selection_buffer_row ({selection_buffer_row})" ); } JumpData::MultiBufferRow { .. } => { panic!("expected MultiBufferPoint jump data when a selection is present") } } }); } #[gpui::test] async fn test_extra_newline_insertion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( Language::new( LanguageConfig { brackets: BracketPairConfig { pairs: vec![ BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }, BracketPair { start: "/* ".to_string(), end: " */".to_string(), close: true, surround: true, newline: true, }, ], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_indents_query("") .unwrap(), ); let text = concat!( "{ }\n", // " x\n", // " /* */\n", // "x\n", // "{{} }\n", // ); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3), DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5), DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4), ]) }); editor.newline(&Newline, window, cx); assert_eq!( editor.buffer().read(cx).read(cx).text(), concat!( "{ \n", // Suppress rustfmt "\n", // "}\n", // " x\n", // " /* \n", // " \n", // " */\n", // "x\n", // "{{} \n", // "}\n", // ) ); }); } #[gpui::test] fn test_highlighted_ranges(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { let buffer = editor.buffer.read(cx).snapshot(cx); let anchor_range = |range: Range| buffer.anchor_after(range.start)..buffer.anchor_after(range.end); editor.highlight_background( HighlightKey::ColorizeBracket(0), &[ anchor_range(Point::new(2, 1)..Point::new(2, 3)), anchor_range(Point::new(4, 2)..Point::new(4, 4)), anchor_range(Point::new(6, 3)..Point::new(6, 5)), anchor_range(Point::new(8, 4)..Point::new(8, 6)), ], |_, _| Hsla::red(), cx, ); editor.highlight_background( HighlightKey::ColorizeBracket(1), &[ anchor_range(Point::new(3, 2)..Point::new(3, 5)), anchor_range(Point::new(5, 3)..Point::new(5, 6)), anchor_range(Point::new(7, 4)..Point::new(7, 7)), anchor_range(Point::new(9, 5)..Point::new(9, 8)), ], |_, _| Hsla::green(), cx, ); let snapshot = editor.snapshot(window, cx); let highlighted_ranges = editor.sorted_background_highlights_in_range( anchor_range(Point::new(3, 4)..Point::new(7, 4)), &snapshot, cx.theme(), ); assert_eq!( highlighted_ranges, &[ ( DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5), Hsla::green(), ), ( DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4), Hsla::red(), ), ( DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6), Hsla::green(), ), ( DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), Hsla::red(), ), ] ); assert_eq!( editor.sorted_background_highlights_in_range( anchor_range(Point::new(5, 6)..Point::new(6, 4)), &snapshot, cx.theme(), ), &[( DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), Hsla::red(), )] ); }); } #[gpui::test] async fn test_copy_highlight_json(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn main() { let x = 1;ˇ } "}); setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.copy_highlight_json(&CopyHighlightJson, window, cx); }); let clipboard_json: serde_json::Value = serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); assert_eq!( clipboard_json, json!([ [ {"text": "fn", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "main", "highlight": "function"}, {"text": "()", "highlight": "punctuation.bracket"}, {"text": " ", "highlight": null}, {"text": "{", "highlight": "punctuation.bracket"}, ], [ {"text": " ", "highlight": null}, {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "x", "highlight": "variable"}, {"text": " ", "highlight": null}, {"text": "=", "highlight": "operator"}, {"text": " ", "highlight": null}, {"text": "1", "highlight": "number"}, {"text": ";", "highlight": "punctuation.delimiter"}, ], [ {"text": "}", "highlight": "punctuation.bracket"}, ], ]) ); } #[gpui::test] async fn test_copy_highlight_json_selected_range(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn main() { «let x = 1; let yˇ» = 2; } "}); setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.copy_highlight_json(&CopyHighlightJson, window, cx); }); let clipboard_json: serde_json::Value = serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); assert_eq!( clipboard_json, json!([ [ {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "x", "highlight": "variable"}, {"text": " ", "highlight": null}, {"text": "=", "highlight": "operator"}, {"text": " ", "highlight": null}, {"text": "1", "highlight": "number"}, {"text": ";", "highlight": "punctuation.delimiter"}, ], [ {"text": " ", "highlight": null}, {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "y", "highlight": "variable"}, ], ]) ); } #[gpui::test] async fn test_copy_highlight_json_selected_line_range(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn main() { «let x = 1; let yˇ» = 2; } "}); setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.selections.set_line_mode(true); editor.copy_highlight_json(&CopyHighlightJson, window, cx); }); let clipboard_json: serde_json::Value = serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); assert_eq!( clipboard_json, json!([ [ {"text": " ", "highlight": null}, {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "x", "highlight": "variable"}, {"text": " ", "highlight": null}, {"text": "=", "highlight": "operator"}, {"text": " ", "highlight": null}, {"text": "1", "highlight": "number"}, {"text": ";", "highlight": "punctuation.delimiter"}, ], [ {"text": " ", "highlight": null}, {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "y", "highlight": "variable"}, {"text": " ", "highlight": null}, {"text": "=", "highlight": "operator"}, {"text": " ", "highlight": null}, {"text": "2", "highlight": "number"}, {"text": ";", "highlight": "punctuation.delimiter"}, ], ]) ); } #[gpui::test] async fn test_copy_highlight_json_single_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn main() { let ˇx = 1; let y = 2; } "}); setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.selections.set_line_mode(true); editor.copy_highlight_json(&CopyHighlightJson, window, cx); }); let clipboard_json: serde_json::Value = serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); assert_eq!( clipboard_json, json!([ [ {"text": " ", "highlight": null}, {"text": "let", "highlight": "keyword"}, {"text": " ", "highlight": null}, {"text": "x", "highlight": "variable"}, {"text": " ", "highlight": null}, {"text": "=", "highlight": "operator"}, {"text": " ", "highlight": null}, {"text": "1", "highlight": "number"}, {"text": ";", "highlight": "punctuation.delimiter"}, ] ]) ); } #[gpui::test] async fn test_following(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; let buffer = project.update(cx, |project, cx| { let buffer = project.create_local_buffer(&sample_text(16, 8, 'a'), None, false, cx); cx.new(|cx| MultiBuffer::singleton(buffer, cx)) }); let leader = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); let follower = cx.update(|cx| { cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::from_corners( gpui::Point::new(px(0.), px(0.)), gpui::Point::new(px(10.), px(80.)), ))), ..Default::default() }, |window, cx| cx.new(|cx| build_editor(buffer.clone(), window, cx)), ) .unwrap() }); let is_still_following = Rc::new(RefCell::new(true)); let follower_edit_event_count = Rc::new(RefCell::new(0)); let pending_update = Rc::new(RefCell::new(None)); let leader_entity = leader.root(cx).unwrap(); let follower_entity = follower.root(cx).unwrap(); _ = follower.update(cx, { let update = pending_update.clone(); let is_still_following = is_still_following.clone(); let follower_edit_event_count = follower_edit_event_count.clone(); |_, window, cx| { cx.subscribe_in( &leader_entity, window, move |_, leader, event, window, cx| { leader.update(cx, |leader, cx| { leader.add_event_to_update_proto( event, &mut update.borrow_mut(), window, cx, ); }); }, ) .detach(); cx.subscribe_in( &follower_entity, window, move |_, _, event: &EditorEvent, _window, _cx| { if matches!(Editor::to_follow_event(event), Some(FollowEvent::Unfollow)) { *is_still_following.borrow_mut() = false; } if let EditorEvent::BufferEdited = event { *follower_edit_event_count.borrow_mut() += 1; } }, ) .detach(); } }); // Update the selections only _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); }); follower .update(cx, |follower, window, cx| { follower.apply_update_proto( &project, pending_update.borrow_mut().take().unwrap(), window, cx, ) }) .unwrap() .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!( follower.selections.ranges(&follower.display_snapshot(cx)), vec![MultiBufferOffset(1)..MultiBufferOffset(1)] ); }); assert!(*is_still_following.borrow()); assert_eq!(*follower_edit_event_count.borrow(), 0); // Update the scroll position only _ = leader.update(cx, |leader, window, cx| { leader.set_scroll_position(gpui::Point::new(1.5, 3.5), window, cx); }); follower .update(cx, |follower, window, cx| { follower.apply_update_proto( &project, pending_update.borrow_mut().take().unwrap(), window, cx, ) }) .unwrap() .await .unwrap(); assert_eq!( follower .update(cx, |follower, _, cx| follower.scroll_position(cx)) .unwrap(), gpui::Point::new(1.5, 3.5) ); assert!(*is_still_following.borrow()); assert_eq!(*follower_edit_event_count.borrow(), 0); // Update the selections and scroll position. The follower's scroll position is updated // via autoscroll, not via the leader's exact scroll position. _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) }); leader.request_autoscroll(Autoscroll::newest(), cx); leader.set_scroll_position(gpui::Point::new(1.5, 3.5), window, cx); }); follower .update(cx, |follower, window, cx| { follower.apply_update_proto( &project, pending_update.borrow_mut().take().unwrap(), window, cx, ) }) .unwrap() .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); assert_eq!( follower.selections.ranges(&follower.display_snapshot(cx)), vec![MultiBufferOffset(0)..MultiBufferOffset(0)] ); }); assert!(*is_still_following.borrow()); // Creating a pending selection that precedes another selection _ = leader.update(cx, |leader, window, cx| { leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) }); leader.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); }); follower .update(cx, |follower, window, cx| { follower.apply_update_proto( &project, pending_update.borrow_mut().take().unwrap(), window, cx, ) }) .unwrap() .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!( follower.selections.ranges(&follower.display_snapshot(cx)), vec![ MultiBufferOffset(0)..MultiBufferOffset(0), MultiBufferOffset(1)..MultiBufferOffset(1) ] ); }); assert!(*is_still_following.borrow()); // Extend the pending selection so that it surrounds another selection _ = leader.update(cx, |leader, window, cx| { leader.extend_selection(DisplayPoint::new(DisplayRow(0), 2), 1, window, cx); }); follower .update(cx, |follower, window, cx| { follower.apply_update_proto( &project, pending_update.borrow_mut().take().unwrap(), window, cx, ) }) .unwrap() .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!( follower.selections.ranges(&follower.display_snapshot(cx)), vec![MultiBufferOffset(0)..MultiBufferOffset(2)] ); }); // Scrolling locally breaks the follow _ = follower.update(cx, |follower, window, cx| { let top_anchor = follower .buffer() .read(cx) .read(cx) .anchor_after(MultiBufferOffset(0)); follower.set_scroll_anchor( ScrollAnchor { anchor: top_anchor, offset: gpui::Point::new(0.0, 0.5), }, window, cx, ); }); assert!(!(*is_still_following.borrow())); } #[gpui::test] async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); let cx = &mut VisualTestContext::from_window(*window, cx); let leader = pane.update_in(cx, |_, window, cx| { let multibuffer = cx.new(|_| MultiBuffer::new(ReadWrite)); cx.new(|cx| build_editor(multibuffer.clone(), window, cx)) }); // Start following the editor when it has no excerpts. let mut state_message = leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); let workspace_entity = workspace.clone(); let follower_1 = cx .update_window(*window, |_, window, cx| { Editor::from_state_proto( workspace_entity, ViewId { creator: CollaboratorId::PeerId(PeerId::default()), id: 0, }, &mut state_message, window, cx, ) }) .unwrap() .unwrap() .await .unwrap(); let update_message = Rc::new(RefCell::new(None)); follower_1.update_in(cx, { let update = update_message.clone(); |_, window, cx| { cx.subscribe_in(&leader, window, move |_, leader, event, window, cx| { leader.update(cx, |leader, cx| { leader.add_event_to_update_proto(event, &mut update.borrow_mut(), window, cx); }); }) .detach(); } }); let (buffer_1, buffer_2) = project.update(cx, |project, cx| { ( project.create_local_buffer("abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyza\nbcd\nefg\nhij\nklm\nnop\nqrs\ntuv\nwxy\nzab\ncde\nfgh\n", None, false, cx), project.create_local_buffer("aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\nkkk\nlll\nmmm\nnnn\nooo\nppp\nqqq\nrrr\nsss\nttt\n", None, false, cx), ) }); // Insert some excerpts. leader.update(cx, |leader, cx| { leader.buffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), buffer_1.clone(), vec![ Point::row_range(0..3), Point::row_range(1..6), Point::row_range(12..15), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(1, rel_path("a.txt").into_arc()), buffer_2.clone(), vec![Point::row_range(0..6), Point::row_range(8..12)], 0, cx, ); }); }); // Apply the update of adding the excerpts. follower_1 .update_in(cx, |follower, window, cx| { follower.apply_update_proto( &project, update_message.borrow().clone().unwrap(), window, cx, ) }) .await .unwrap(); assert_eq!( follower_1.update(cx, |editor, cx| editor.text(cx)), leader.update(cx, |editor, cx| editor.text(cx)) ); update_message.borrow_mut().take(); // Start following separately after it already has excerpts. let mut state_message = leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); let workspace_entity = workspace.clone(); let follower_2 = cx .update_window(*window, |_, window, cx| { Editor::from_state_proto( workspace_entity, ViewId { creator: CollaboratorId::PeerId(PeerId::default()), id: 0, }, &mut state_message, window, cx, ) }) .unwrap() .unwrap() .await .unwrap(); assert_eq!( follower_2.update(cx, |editor, cx| editor.text(cx)), leader.update(cx, |editor, cx| editor.text(cx)) ); // Remove some excerpts. leader.update(cx, |leader, cx| { leader.buffer.update(cx, |multibuffer, cx| { multibuffer.remove_excerpts( PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), cx, ); }); }); // Apply the update of removing the excerpts. follower_1 .update_in(cx, |follower, window, cx| { follower.apply_update_proto( &project, update_message.borrow().clone().unwrap(), window, cx, ) }) .await .unwrap(); follower_2 .update_in(cx, |follower, window, cx| { follower.apply_update_proto( &project, update_message.borrow().clone().unwrap(), window, cx, ) }) .await .unwrap(); update_message.borrow_mut().take(); assert_eq!( follower_1.update(cx, |editor, cx| editor.text(cx)), leader.update(cx, |editor, cx| editor.text(cx)) ); } #[gpui::test] async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let lsp_store = cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); cx.set_state(indoc! {" ˇfn func(abc def: i32) -> u32 { } "}); cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 11), lsp::Position::new(0, 12), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 12), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 25), lsp::Position::new(0, 28), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, ], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); }); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); } #[gpui::test] async fn go_to_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let lsp_store = cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); // Place the cursor inside the `def` diagnostic (`[12, 15)`) before any // diagnostic is active so we can later confirm that running `editor: go to // diagnostic` will activate this diagnostic instead of advancing to the // next one. cx.set_state(indoc! {" fn func(abc dˇef: i32) -> u32 { } "}); // Set up the diagnostics: // // * `[11, 12)` (the space before `def`), // * `[12, 15)` (`def`), // * `[25, 28)` (`u32`). cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 11), lsp::Position::new(0, 12), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 12), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 25), lsp::Position::new(0, 28), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, ], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); }); executor.run_until_parked(); // When the cursor is at an inactive diagnostic, cursor should be moved to // the start of that same diagnostic and activate it. cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); // Manually move the cursor to a different, not yet active diagnostic to // confirm that using `editor: go to diagnostic` will now activate this one. cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges([Point::new(0, 26)..Point::new(0, 26)]) }); }); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); cx.update_editor(|editor, window, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) }); }); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); } #[gpui::test] async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod; const A: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(); // Edits are modified, removed, modified, added cx.set_state( &r#" use some::modified; ˇ fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { //Wrap around the bottom of the buffer for _ in 0..3 { editor.go_to_next_hunk(&GoToHunk, window, cx); } }); cx.assert_editor_state( &r#" ˇuse some::modified; fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { //Wrap around the top of the buffer for _ in 0..2 { editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); } }); cx.assert_editor_state( &r#" use some::modified; fn main() { ˇ println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); }); cx.assert_editor_state( &r#" use some::modified; ˇ fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); }); cx.assert_editor_state( &r#" ˇuse some::modified; fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { for _ in 0..2 { editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); } }); cx.assert_editor_state( &r#" use some::modified; fn main() { ˇ println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.fold(&Fold, window, cx); }); cx.update_editor(|editor, window, cx| { editor.go_to_next_hunk(&GoToHunk, window, cx); }); cx.assert_editor_state( &r#" ˇuse some::modified; fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); } #[test] fn test_split_words() { fn split(text: &str) -> Vec<&str> { split_words(text).collect() } assert_eq!(split("HelloWorld"), &["Hello", "World"]); assert_eq!(split("hello_world"), &["hello_", "world"]); assert_eq!(split("_hello_world_"), &["_", "hello_", "world_"]); assert_eq!(split("Hello_World"), &["Hello_", "World"]); assert_eq!(split("helloWOrld"), &["hello", "WOrld"]); assert_eq!(split("helloworld"), &["helloworld"]); assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]); } #[test] fn test_split_words_for_snippet_prefix() { fn split(text: &str) -> Vec<&str> { snippet_candidate_suffixes(text, &|c| c.is_alphanumeric() || c == '_').collect() } assert_eq!(split("HelloWorld"), &["HelloWorld"]); assert_eq!(split("hello_world"), &["hello_world"]); assert_eq!(split("_hello_world_"), &["_hello_world_"]); assert_eq!(split("Hello_World"), &["Hello_World"]); assert_eq!(split("helloWOrld"), &["helloWOrld"]); assert_eq!(split("helloworld"), &["helloworld"]); assert_eq!( split("this@is!@#$^many . symbols"), &[ "symbols", " symbols", ". symbols", " . symbols", " . symbols", " . symbols", "many . symbols", "^many . symbols", "$^many . symbols", "#$^many . symbols", "@#$^many . symbols", "!@#$^many . symbols", "is!@#$^many . symbols", "@is!@#$^many . symbols", "this@is!@#$^many . symbols", ], ); assert_eq!(split("a.s"), &["s", ".s", "a.s"]); } #[gpui::test] async fn test_move_to_syntax_node_relative_jumps(tcx: &mut TestAppContext) { init_test(tcx, |_| {}); let mut cx = EditorLspTestContext::new( Arc::into_inner(markdown_lang()).unwrap(), Default::default(), tcx, ) .await; async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { let _state_context = cx.set_state(before); cx.run_until_parked(); cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) .await .unwrap(); cx.run_until_parked(); cx.assert_editor_state(after); } const ABOVE: i8 = -1; const BELOW: i8 = 1; assert( ABOVE, indoc! {" # Foo ˇFoo foo foo # Bar Bar bar bar "}, indoc! {" ˇ# Foo Foo foo foo # Bar Bar bar bar "}, &mut cx, ) .await; assert( ABOVE, indoc! {" ˇ# Foo Foo foo foo # Bar Bar bar bar "}, indoc! {" ˇ# Foo Foo foo foo # Bar Bar bar bar "}, &mut cx, ) .await; assert( BELOW, indoc! {" ˇ# Foo Foo foo foo # Bar Bar bar bar "}, indoc! {" # Foo Foo foo foo ˇ# Bar Bar bar bar "}, &mut cx, ) .await; assert( BELOW, indoc! {" # Foo ˇFoo foo foo # Bar Bar bar bar "}, indoc! {" # Foo Foo foo foo ˇ# Bar Bar bar bar "}, &mut cx, ) .await; assert( BELOW, indoc! {" # Foo Foo foo foo ˇ# Bar Bar bar bar "}, indoc! {" # Foo Foo foo foo ˇ# Bar Bar bar bar "}, &mut cx, ) .await; assert( BELOW, indoc! {" # Foo Foo foo foo # Bar ˇ Bar bar bar "}, indoc! {" # Foo Foo foo foo # Bar ˇ Bar bar bar "}, &mut cx, ) .await; } #[gpui::test] async fn test_move_to_syntax_node_relative_dead_zone(tcx: &mut TestAppContext) { init_test(tcx, |_| {}); let mut cx = EditorLspTestContext::new( Arc::into_inner(rust_lang()).unwrap(), Default::default(), tcx, ) .await; async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { let _state_context = cx.set_state(before); cx.run_until_parked(); cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) .await .unwrap(); cx.run_until_parked(); cx.assert_editor_state(after); } const ABOVE: i8 = -1; const BELOW: i8 = 1; assert( ABOVE, indoc! {" fn foo() { // foo fn } ˇ// this zone is not inside any top level outline node fn bar() { // bar fn let _ = 2; } "}, indoc! {" ˇfn foo() { // foo fn } // this zone is not inside any top level outline node fn bar() { // bar fn let _ = 2; } "}, &mut cx, ) .await; assert( BELOW, indoc! {" fn foo() { // foo fn } ˇ// this zone is not inside any top level outline node fn bar() { // bar fn let _ = 2; } "}, indoc! {" fn foo() { // foo fn } // this zone is not inside any top level outline node ˇfn bar() { // bar fn let _ = 2; } "}, &mut cx, ) .await; } #[gpui::test] async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; #[track_caller] fn assert(before: &str, after: &str, cx: &mut EditorLspTestContext) { let _state_context = cx.set_state(before); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx) }); cx.run_until_parked(); cx.assert_editor_state(after); } // Outside bracket jumps to outside of matching bracket assert("console.logˇ(var);", "console.log(var)ˇ;", &mut cx); assert("console.log(var)ˇ;", "console.logˇ(var);", &mut cx); // Inside bracket jumps to inside of matching bracket assert("console.log(ˇvar);", "console.log(varˇ);", &mut cx); assert("console.log(varˇ);", "console.log(ˇvar);", &mut cx); // When outside a bracket and inside, favor jumping to the inside bracket assert( "console.log('foo', [1, 2, 3]ˇ);", "console.log('foo', ˇ[1, 2, 3]);", &mut cx, ); assert( "console.log(ˇ'foo', [1, 2, 3]);", "console.log('foo'ˇ, [1, 2, 3]);", &mut cx, ); // Bias forward if two options are equally likely assert( "let result = curried_fun()ˇ();", "let result = curried_fun()()ˇ;", &mut cx, ); // If directly adjacent to a smaller pair but inside a larger (not adjacent), pick the smaller assert( indoc! {" function test() { console.log('test')ˇ }"}, indoc! {" function test() { console.logˇ('test') }"}, &mut cx, ); } #[gpui::test] async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); language_registry.add(markdown_lang()); language_registry.add(rust_lang()); let buffer = cx.new(|cx| { let mut buffer = language::Buffer::local( indoc! {" ```rs impl Worktree { pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { } } ``` "}, cx, ); buffer.set_language_registry(language_registry.clone()); buffer.set_language(Some(markdown_lang()), cx); buffer }); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); cx.executor().run_until_parked(); _ = editor.update(cx, |editor, window, cx| { // Case 1: Test outer enclosing brackets select_ranges( editor, &indoc! {" ```rs impl Worktree { pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { } }ˇ ``` "}, window, cx, ); editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); assert_text_with_selections( editor, &indoc! {" ```rs impl Worktree ˇ{ pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { } } ``` "}, cx, ); // Case 2: Test inner enclosing brackets select_ranges( editor, &indoc! {" ```rs impl Worktree { pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { }ˇ } ``` "}, window, cx, ); editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); assert_text_with_selections( editor, &indoc! {" ```rs impl Worktree { pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{ } } ``` "}, cx, ); }); } #[gpui::test] async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "fn main() { let a = 5; }", "other.rs": "// Test file", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "Rust".into(), matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() }) .into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }], disabled_scopes_by_bracket_ix: Vec::new(), }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ))); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { first_trigger_character: "{".to_string(), more_trigger_character: None, }), ..Default::default() }, ..Default::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(cx, |workspace, _, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) }) .await .unwrap(); let editor_handle = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); let fake_server = fake_servers.next().await.unwrap(); fake_server.set_request_handler::( |params, _| async move { assert_eq!( params.text_document_position.text_document.uri, lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), ); assert_eq!( params.text_document_position.position, lsp::Position::new(0, 21), ); Ok(Some(vec![lsp::TextEdit { new_text: "]".to_string(), range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), }])) }, ); editor_handle.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 21)..Point::new(0, 20)]) }); editor.handle_input("{", window, cx); }); cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer.text(), "fn main() { let a = {5}; }", "No extra braces from on type formatting should appear in the buffer" ) }); } #[gpui::test(iterations = 20, seeds(31))] async fn test_on_type_formatting_is_applied_after_autoindent(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { first_trigger_character: ".".to_string(), more_trigger_character: None, }), ..Default::default() }, cx, ) .await; cx.update_buffer(|buffer, _| { // This causes autoindent to be async. buffer.set_sync_parse_timeout(None) }); cx.set_state("fn c() {\n d()ˇ\n}\n"); cx.simulate_keystroke("\n"); cx.run_until_parked(); let buffer_cloned = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); let mut request = cx.set_request_handler::(move |_, _, mut cx| { let buffer_cloned = buffer_cloned.clone(); async move { buffer_cloned.update(&mut cx, |buffer, _| { assert_eq!( buffer.text(), "fn c() {\n d()\n .\n}\n", "OnTypeFormatting should triggered after autoindent applied" ) }); Ok(Some(vec![])) } }); cx.simulate_keystroke("."); cx.run_until_parked(); cx.assert_editor_state("fn c() {\n d()\n .ˇ\n}\n"); assert!(request.next().await.is_some()); request.close(); assert!(request.next().await.is_none()); } #[gpui::test] async fn test_on_type_formatting_not_scheduled_without_server_support(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; cx.set_state("let a = bˇ;\n"); let task = cx.update_editor(|editor, window, cx| { editor.trigger_on_type_formatting(".".to_owned(), window, cx) }); assert!(task.is_none()); } #[gpui::test] async fn test_on_type_formatting_preserves_cursor_position(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { first_trigger_character: ".".to_string(), more_trigger_character: None, }), ..Default::default() }, cx, ) .await; cx.set_state("let a = bˇ;\n"); let mut request = cx.set_request_handler::(|_, params, _| async move { assert_eq!( params.text_document_position.position, lsp::Position::new(0, 10) ); Ok(Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 11)), new_text: "c;".to_string(), }])) }); cx.simulate_keystroke("."); cx.run_until_parked(); cx.assert_editor_state("let a = b.ˇc;\n"); assert!(request.next().await.is_some()); } #[gpui::test] async fn test_on_type_formatting_does_not_rewind_over_intervening_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { first_trigger_character: ".".to_string(), more_trigger_character: None, }), ..Default::default() }, cx, ) .await; cx.set_state("let a = bˇ;\n"); let (request_started_tx, request_started_rx) = oneshot::channel(); let (respond_tx, respond_rx) = oneshot::channel(); let mut request_started_tx = Some(request_started_tx); let mut respond_rx = Some(respond_rx); let _request = cx.set_request_handler::(move |_, _, _| { request_started_tx.take().unwrap().send(()).unwrap(); let respond_rx = respond_rx.take().unwrap(); async move { respond_rx.await.unwrap(); Ok(Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 10)), new_text: "c".to_string(), }])) } }); cx.simulate_keystroke("."); request_started_rx.await.unwrap(); cx.update_buffer(|buffer, cx| buffer.edit([(10..10, "x")], None, cx)); respond_tx.send(()).unwrap(); cx.run_until_parked(); cx.assert_editor_state("let a = b.cxˇ;\n"); } #[gpui::test] async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "fn main() { let a = 5; }", "other.rs": "// Test file", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let server_restarts = Arc::new(AtomicUsize::new(0)); let closure_restarts = Arc::clone(&server_restarts); let language_server_name = "test language server"; let language_name: LanguageName = "Rust".into(); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: language_name.clone(), matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() }) .into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ))); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: language_server_name, initialization_options: Some(json!({ "testOptionValue": true })), initializer: Some(Box::new(move |fake_server| { let task_restarts = Arc::clone(&closure_restarts); fake_server.set_request_handler::(move |_, _| { task_restarts.fetch_add(1, atomic::Ordering::Release); futures::future::ready(Ok(())) }); })), ..Default::default() }, ); let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let _buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) }) .await .unwrap(); let _fake_server = fake_servers.next().await.unwrap(); update_test_language_settings(cx, &|language_settings| { language_settings.languages.0.insert( language_name.clone().0.to_string(), LanguageSettingsContent { tab_size: NonZeroU32::new(8), ..Default::default() }, ); }); cx.executor().run_until_parked(); assert_eq!( server_restarts.load(atomic::Ordering::Acquire), 0, "Should not restart LSP server on an unrelated change" ); update_test_project_settings(cx, &|project_settings| { project_settings.lsp.0.insert( "Some other server name".into(), LspSettings { binary: None, settings: None, initialization_options: Some(json!({ "some other init value": false })), enable_lsp_tasks: false, fetch: None, }, ); }); cx.executor().run_until_parked(); assert_eq!( server_restarts.load(atomic::Ordering::Acquire), 0, "Should not restart LSP server on an unrelated LSP settings change" ); update_test_project_settings(cx, &|project_settings| { project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, settings: None, initialization_options: Some(json!({ "anotherInitValue": false })), enable_lsp_tasks: false, fetch: None, }, ); }); cx.executor().run_until_parked(); assert_eq!( server_restarts.load(atomic::Ordering::Acquire), 1, "Should restart LSP server on a related LSP settings change" ); update_test_project_settings(cx, &|project_settings| { project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, settings: None, initialization_options: Some(json!({ "anotherInitValue": false })), enable_lsp_tasks: false, fetch: None, }, ); }); cx.executor().run_until_parked(); assert_eq!( server_restarts.load(atomic::Ordering::Acquire), 1, "Should not restart LSP server on a related LSP settings change that is the same" ); update_test_project_settings(cx, &|project_settings| { project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, settings: None, initialization_options: None, enable_lsp_tasks: false, fetch: None, }, ); }); cx.executor().run_until_parked(); assert_eq!( server_restarts.load(atomic::Ordering::Acquire), 2, "Should restart LSP server on another related LSP settings change" ); } #[gpui::test] async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let completion_item = lsp::CompletionItem { label: "some".into(), kind: Some(lsp::CompletionItemKind::SNIPPET), detail: Some("Wrap the expression in an `Option::Some`".to_string()), documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "```rust\nSome(2)\n```".to_string(), })), deprecated: Some(false), sort_text: Some("fffffff2".to_string()), filter_text: Some("some".to_string()), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 22, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "Some(2)".to_string(), })), additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 20, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "".to_string(), }]), ..Default::default() }; let closure_completion_item = completion_item.clone(); let mut request = cx.set_request_handler::(move |_, _, _| { let task_completion_item = closure_completion_item.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ task_completion_item, ]))) } }); request.next().await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state("fn main() { let a = 2.Some(2)ˇ; }"); cx.set_request_handler::(move |_, _, _| { let task_completion_item = completion_item.clone(); async move { Ok(task_completion_item) } }) .next() .await .unwrap(); apply_additional_edits.await.unwrap(); cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); } async fn check_completion_additional_edits( initial_state: &str, keystroke: &str, completion_item: lsp::CompletionItem, state_after_primary_edit: &str, state_after_additional_edits: &str, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state(initial_state); let closure_completion_item = completion_item.clone(); let mut request = cx.set_request_handler::(move |_, _, _| { let task_completion_item = closure_completion_item.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ task_completion_item, ]))) } }); cx.simulate_keystroke(keystroke); request.next().await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state(state_after_primary_edit); cx.set_request_handler::(move |_, _, _| { let task_completion_item = completion_item.clone(); async move { Ok(task_completion_item) } }) .next() .await .unwrap(); apply_additional_edits.await.unwrap(); cx.assert_editor_state(state_after_additional_edits); } // rust-analyzer's ref-match completions (`&some_str`) deliver the `&` as a // zero-width additionalTextEdit at the primary edit's start. // Ref: https://github.com/zed-industries/zed/issues/56973 #[gpui::test] async fn test_completions_with_zero_width_additional_edit_at_primary_edit_start( cx: &mut TestAppContext, ) { check_completion_additional_edits( // The completion must not start at (0, 0), so that this case is // distinct from the file-start auto-import test below. "bar(fˇ)", "o", lsp::CompletionItem { label: "&foo".to_string(), filter_text: Some("foo".to_string()), // Replaces the typed `fo` with `foo`; the committed range is 4..7. text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 6)), new_text: "foo".to_string(), })), // Zero-width insertion exactly at the committed range's start. additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)), new_text: "&".to_string(), }]), ..Default::default() }, "bar(fooˇ)", "bar(&fooˇ)", cx, ) .await; } // Additional edits which overlap the primary completion edit must be skipped // while non-overlapping edits from the same completion are still applied. // Ref: https://github.com/zed-industries/zed/pull/1871 #[gpui::test] async fn test_completions_skip_additional_edits_overlapping_primary_edit(cx: &mut TestAppContext) { check_completion_additional_edits( "fˇ\nbar", "o", lsp::CompletionItem { label: "foo".to_string(), filter_text: Some("foo".to_string()), // Replaces the typed `fo` with `foo`; the committed range is 0..3. text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 2)), new_text: "foo".to_string(), })), additional_text_edits: Some(vec![ // Skip text strictly inside the committed range. lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 1), lsp::Position::new(0, 2)), new_text: "XXX".to_string(), }, // Apply text which does not touch the committed range. lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), new_text: "baz".to_string(), }, ]), ..Default::default() }, "fooˇ\nbar", "fooˇ\nbaz", cx, ) .await; } // When both the primary completion edit and an additional edit (auto-import) // start at the very beginning of the file, the additional edit must not be // treated as overlapping. This payload shape matches what // typescript-language-server actually sends for auto-imports at file start. // Ref: https://github.com/zed-industries/zed/issues/26136 #[gpui::test] async fn test_completions_with_file_start_auto_import_additional_edit(cx: &mut TestAppContext) { check_completion_additional_edits( "ˇ", "f", lsp::CompletionItem { label: "foo".to_string(), // Replaces the typed `f` at file start; the committed range is 0..3. text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), new_text: "foo".to_string(), })), // Auto-import inserted at the very start of the file. additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), new_text: "bar\n".to_string(), }]), ..Default::default() }, "fooˇ", "bar\nfooˇ", cx, ) .await; } #[gpui::test] async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let completion_item = lsp::CompletionItem { label: "some".into(), kind: Some(lsp::CompletionItemKind::SNIPPET), detail: Some("Wrap the expression in an `Option::Some`".to_string()), documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "```rust\nSome(2)\n```".to_string(), })), deprecated: Some(false), sort_text: Some("fffffff2".to_string()), filter_text: Some("some".to_string()), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 22, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "Some(2)".to_string(), })), additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 20, }, end: lsp::Position { line: 0, character: 22, }, }, new_text: "".to_string(), }]), ..Default::default() }; let closure_completion_item = completion_item.clone(); let mut request = cx.set_request_handler::(move |_, _, _| { let task_completion_item = closure_completion_item.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ task_completion_item, ]))) } }); request.next().await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state("fn main() { let a = 2.Some(2)ˇ; }"); cx.set_request_handler::(move |_, _, _| { let task_completion_item = completion_item.clone(); async move { Ok(task_completion_item) } }) .next() .await .unwrap(); apply_additional_edits.await.unwrap(); cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); cx.update_editor(|editor, window, cx| { editor.undo(&crate::Undo, window, cx); }); cx.assert_editor_state("fn main() { let a = 2.ˇ; }"); } #[gpui::test] async fn test_completions_with_additional_edits_and_multiple_cursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_typescript( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state( "import { «Fooˇ» } from './types';\n\nclass Bar {\n method(): «Fooˇ» { return new Foo(); }\n}", ); cx.simulate_keystroke("F"); cx.simulate_keystroke("o"); let completion_item = lsp::CompletionItem { label: "FooBar".into(), kind: Some(lsp::CompletionItemKind::CLASS), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 3, character: 14, }, end: lsp::Position { line: 3, character: 16, }, }, new_text: "FooBar".to_string(), })), additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 9, }, end: lsp::Position { line: 0, character: 11, }, }, new_text: "FooBar".to_string(), }]), ..Default::default() }; let closure_completion_item = completion_item.clone(); let mut request = cx.set_request_handler::(move |_, _, _| { let task_completion_item = closure_completion_item.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ task_completion_item, ]))) } }); request.next().await; cx.condition(|editor, _| editor.context_menu_visible()) .await; let apply_additional_edits = cx.update_editor(|editor, window, cx| { editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state( "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", ); cx.set_request_handler::(move |_, _, _| { let task_completion_item = completion_item.clone(); async move { Ok(task_completion_item) } }) .next() .await .unwrap(); apply_additional_edits.await.unwrap(); cx.assert_editor_state( "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", ); } #[gpui::test] async fn test_completions_resolve_updates_labels_if_filter_text_matches(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let item1 = lsp::CompletionItem { label: "method id()".to_string(), filter_text: Some("id".to_string()), detail: None, documentation: None, text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), new_text: ".id".to_string(), })), ..lsp::CompletionItem::default() }; let item2 = lsp::CompletionItem { label: "other".to_string(), filter_text: Some("other".to_string()), detail: None, documentation: None, text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), new_text: ".other".to_string(), })), ..lsp::CompletionItem::default() }; let item1 = item1.clone(); cx.set_request_handler::({ let item1 = item1.clone(); move |_, _, _| { let item1 = item1.clone(); let item2 = item2.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![item1, item2]))) } } }) .next() .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { let context_menu = editor.context_menu.borrow_mut(); let context_menu = context_menu .as_ref() .expect("Should have the context menu deployed"); match context_menu { CodeContextMenu::Completions(completions_menu) => { let completions = completions_menu.completions.borrow_mut(); assert_eq!( completions .iter() .map(|completion| &completion.label.text) .collect::>(), vec!["method id()", "other"] ) } CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), } }); cx.set_request_handler::({ let item1 = item1.clone(); move |_, item_to_resolve, _| { let item1 = item1.clone(); async move { if item1 == item_to_resolve { Ok(lsp::CompletionItem { label: "method id()".to_string(), filter_text: Some("id".to_string()), detail: Some("Now resolved!".to_string()), documentation: Some(lsp::Documentation::String("Docs".to_string())), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 22), lsp::Position::new(0, 22), ), new_text: ".id".to_string(), })), ..lsp::CompletionItem::default() }) } else { Ok(item_to_resolve) } } } }) .next() .await .unwrap(); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.context_menu_next(&Default::default(), window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, _, _| { let context_menu = editor.context_menu.borrow_mut(); let context_menu = context_menu .as_ref() .expect("Should have the context menu deployed"); match context_menu { CodeContextMenu::Completions(completions_menu) => { let completions = completions_menu.completions.borrow_mut(); assert_eq!( completions .iter() .map(|completion| &completion.label.text) .collect::>(), vec!["method id() Now resolved!", "other"], "Should update first completion label, but not second as the filter text did not match." ); } CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), } }); } #[gpui::test] async fn test_context_menus_hide_hover_popover(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" struct TestStruct { field: i32 } fn mainˇ() { let unused_var = 42; let test_struct = TestStruct { field: 42 }; } "}); let symbol_range = cx.lsp_range(indoc! {" struct TestStruct { field: i32 } «fn main»() { let unused_var = 42; let test_struct = TestStruct { field: 42 }; } "}); let mut hover_requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "Function documentation".to_string(), }), range: Some(symbol_range), })) }); // Case 1: Test that code action menu hide hover popover cx.dispatch_action(Hover); hover_requests.next().await; cx.condition(|editor, _| editor.hover_state.visible()).await; let mut code_action_requests = cx.set_request_handler::( move |_, _, _| async move { Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { title: "Remove unused variable".to_string(), kind: Some(CodeActionKind::QUICKFIX), edit: Some(lsp::WorkspaceEdit { changes: Some( [( lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(5, 4), lsp::Position::new(5, 27), ), new_text: "".to_string(), }], )] .into_iter() .collect(), ), ..Default::default() }), ..Default::default() }, )])) }, ); cx.update_editor(|editor, window, cx| { editor.toggle_code_actions( &ToggleCodeActions { deployed_from: None, quick_launch: false, }, window, cx, ); }); code_action_requests.next().await; cx.run_until_parked(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Hover popover should be hidden when code action menu is shown" ); // Hide code actions editor.context_menu.take(); }); // Case 2: Test that code completions hide hover popover cx.dispatch_action(Hover); hover_requests.next().await; cx.condition(|editor, _| editor.hover_state.visible()).await; let counter = Arc::new(AtomicUsize::new(0)); let mut completion_requests = cx.set_request_handler::(move |_, _, _| { let counter = counter.clone(); async move { counter.fetch_add(1, atomic::Ordering::Release); Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "main".into(), kind: Some(lsp::CompletionItemKind::FUNCTION), detail: Some("() -> ()".to_string()), ..Default::default() }, lsp::CompletionItem { label: "TestStruct".into(), kind: Some(lsp::CompletionItemKind::STRUCT), detail: Some("struct TestStruct".to_string()), ..Default::default() }, ]))) } }); cx.update_editor(|editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); completion_requests.next().await; cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Hover popover should be hidden when completion menu is shown" ); }); } #[gpui::test] async fn test_completions_resolve_happens_once(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let unresolved_item_1 = lsp::CompletionItem { label: "id".to_string(), filter_text: Some("id".to_string()), detail: None, documentation: None, text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), new_text: ".id".to_string(), })), ..lsp::CompletionItem::default() }; let resolved_item_1 = lsp::CompletionItem { additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)), new_text: "!!".to_string(), }]), ..unresolved_item_1.clone() }; let unresolved_item_2 = lsp::CompletionItem { label: "other".to_string(), filter_text: Some("other".to_string()), detail: None, documentation: None, text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), new_text: ".other".to_string(), })), ..lsp::CompletionItem::default() }; let resolved_item_2 = lsp::CompletionItem { additional_text_edits: Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)), new_text: "??".to_string(), }]), ..unresolved_item_2.clone() }; let resolve_requests_1 = Arc::new(AtomicUsize::new(0)); let resolve_requests_2 = Arc::new(AtomicUsize::new(0)); cx.lsp .server .on_request::({ let unresolved_item_1 = unresolved_item_1.clone(); let resolved_item_1 = resolved_item_1.clone(); let unresolved_item_2 = unresolved_item_2.clone(); let resolved_item_2 = resolved_item_2.clone(); let resolve_requests_1 = resolve_requests_1.clone(); let resolve_requests_2 = resolve_requests_2.clone(); move |unresolved_request, _| { let unresolved_item_1 = unresolved_item_1.clone(); let resolved_item_1 = resolved_item_1.clone(); let unresolved_item_2 = unresolved_item_2.clone(); let resolved_item_2 = resolved_item_2.clone(); let resolve_requests_1 = resolve_requests_1.clone(); let resolve_requests_2 = resolve_requests_2.clone(); async move { if unresolved_request == unresolved_item_1 { resolve_requests_1.fetch_add(1, atomic::Ordering::Release); Ok(resolved_item_1.clone()) } else if unresolved_request == unresolved_item_2 { resolve_requests_2.fetch_add(1, atomic::Ordering::Release); Ok(resolved_item_2.clone()) } else { panic!("Unexpected completion item {unresolved_request:?}") } } } }) .detach(); cx.set_request_handler::(move |_, _, _| { let unresolved_item_1 = unresolved_item_1.clone(); let unresolved_item_2 = unresolved_item_2.clone(); async move { Ok(Some(lsp::CompletionResponse::Array(vec![ unresolved_item_1, unresolved_item_2, ]))) } }) .next() .await; cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.update_editor(|editor, _, _| { let context_menu = editor.context_menu.borrow_mut(); let context_menu = context_menu .as_ref() .expect("Should have the context menu deployed"); match context_menu { CodeContextMenu::Completions(completions_menu) => { let completions = completions_menu.completions.borrow_mut(); assert_eq!( completions .iter() .map(|completion| &completion.label.text) .collect::>(), vec!["id", "other"] ) } CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), } }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.context_menu_next(&ContextMenuNext, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.context_menu_prev(&ContextMenuPrevious, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.context_menu_next(&ContextMenuNext, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor .compose_completion(&ComposeCompletion::default(), window, cx) .expect("No task returned") }) .await .expect("Completion failed"); cx.run_until_parked(); cx.update_editor(|editor, _, cx| { assert_eq!( resolve_requests_1.load(atomic::Ordering::Acquire), 1, "Should always resolve once despite multiple selections" ); assert_eq!( resolve_requests_2.load(atomic::Ordering::Acquire), 1, "Should always resolve once after multiple selections and applying the completion" ); assert_eq!( editor.text(cx), "fn main() { let a = ??.other; }", "Should use resolved data when applying the completion" ); }); } #[gpui::test] async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext) { init_test(cx, |_| {}); let item_0 = lsp::CompletionItem { label: "abs".into(), insert_text: Some("abs".into()), data: Some(json!({ "very": "special"})), insert_text_mode: Some(lsp::InsertTextMode::ADJUST_INDENTATION), text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( lsp::InsertReplaceEdit { new_text: "abs".to_string(), insert: lsp::Range::default(), replace: lsp::Range::default(), }, )), ..lsp::CompletionItem::default() }; let items = iter::once(item_0.clone()) .chain((11..51).map(|i| lsp::CompletionItem { label: format!("item_{}", i), insert_text: Some(format!("item_{}", i)), insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), ..lsp::CompletionItem::default() })) .collect::>(); let default_commit_characters = vec!["?".to_string()]; let default_data = json!({ "default": "data"}); let default_insert_text_format = lsp::InsertTextFormat::SNIPPET; let default_insert_text_mode = lsp::InsertTextMode::AS_IS; let default_edit_range = lsp::Range { start: lsp::Position { line: 0, character: 5, }, end: lsp::Position { line: 0, character: 5, }, }; let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.set_state("fn main() { let a = 2ˇ; }"); cx.simulate_keystroke("."); let completion_data = default_data.clone(); let completion_characters = default_commit_characters.clone(); let completion_items = items.clone(); cx.set_request_handler::(move |_, _, _| { let default_data = completion_data.clone(); let default_commit_characters = completion_characters.clone(); let items = completion_items.clone(); async move { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { items, item_defaults: Some(lsp::CompletionListItemDefaults { data: Some(default_data.clone()), commit_characters: Some(default_commit_characters.clone()), edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( default_edit_range, )), insert_text_format: Some(default_insert_text_format), insert_text_mode: Some(default_insert_text_mode), }), ..lsp::CompletionList::default() }))) } }) .next() .await; let resolved_items = Arc::new(Mutex::new(Vec::new())); cx.lsp .server .on_request::({ let closure_resolved_items = resolved_items.clone(); move |item_to_resolve, _| { let closure_resolved_items = closure_resolved_items.clone(); async move { closure_resolved_items.lock().push(item_to_resolve.clone()); Ok(item_to_resolve) } } }) .detach(); cx.condition(|editor, _| editor.context_menu_visible()) .await; cx.run_until_parked(); cx.update_editor(|editor, _, _| { let menu = editor.context_menu.borrow_mut(); match menu.as_ref().expect("should have the completions menu") { CodeContextMenu::Completions(completions_menu) => { assert_eq!( completions_menu .entries .borrow() .iter() .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) .collect::>(), items .iter() .map(|completion| completion.label.clone()) .collect::>() ); } CodeContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"), } }); // Approximate initial displayed interval is 0..12. With extra item padding of 4 this is 0..16 // with 4 from the end. assert_eq!( *resolved_items.lock(), [&items[0..16], &items[items.len() - 4..items.len()]] .concat() .iter() .cloned() .map(|mut item| { if item.data.is_none() { item.data = Some(default_data.clone()); } item }) .collect::>(), "Items sent for resolve should be unchanged modulo resolve `data` filled with default if missing" ); resolved_items.lock().clear(); cx.update_editor(|editor, window, cx| { editor.context_menu_prev(&ContextMenuPrevious, window, cx); }); cx.run_until_parked(); // Completions that have already been resolved are skipped. assert_eq!( *resolved_items.lock(), items[items.len() - 17..items.len() - 4] .iter() .cloned() .map(|mut item| { if item.data.is_none() { item.data = Some(default_data.clone()); } item }) .collect::>() ); resolved_items.lock().clear(); } #[gpui::test] async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new( Language::new( LanguageConfig { matcher: (LanguageMatcher { path_suffixes: vec!["jsx".into()], ..Default::default() }) .into(), overrides: [( "element".into(), LanguageConfigOverride { completion_query_characters: Override::Set(['-'].into_iter().collect()), ..Default::default() }, )] .into_iter() .collect(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), ) .with_override_query("(jsx_self_closing_element) @element") .unwrap(), lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), ..Default::default() }), ..Default::default() }, cx, ) .await; cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "bg-blue".into(), ..Default::default() }, lsp::CompletionItem { label: "bg-red".into(), ..Default::default() }, lsp::CompletionItem { label: "bg-yellow".into(), ..Default::default() }, ]))) }); cx.set_state(r#"

"#); // Trigger completion when typing a dash, because the dash is an extra // word character in the 'element' scope, which contains the cursor. cx.simulate_keystroke("-"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( completion_menu_entries(menu), &["bg-blue", "bg-red", "bg-yellow"] ); } else { panic!("expected completion menu to be open"); } }); cx.simulate_keystroke("l"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["bg-blue", "bg-yellow"]); } else { panic!("expected completion menu to be open"); } }); // When filtering completions, consider the character after the '-' to // be the start of a subword. cx.set_state(r#"

"#); cx.simulate_keystroke("l"); cx.executor().run_until_parked(); cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!(completion_menu_entries(menu), &["bg-yellow"]); } else { panic!("expected completion menu to be open"); } }); } fn completion_menu_entries(menu: &CompletionsMenu) -> Vec { let entries = menu.entries.borrow(); entries .iter() .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) .collect() } #[gpui::test] async fn test_document_format_with_prettier(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.ts"), Default::default()).await; let project = Project::test(fs, [path!("/file.ts").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }) .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); update_test_language_settings(cx, &|settings| { settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let test_plugin = "test_plugin"; let _ = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { prettier_plugins: vec![test_plugin], ..Default::default() }, ); let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.ts"), cx) }) .await .unwrap(); let buffer_text = "one\ntwo\nthree\n"; let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor.update_in(cx, |editor, window, cx| { editor.set_text(buffer_text, window, cx) }); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), buffer_text.to_string() + prettier_format_suffix, "Test prettier formatting was not applied to the original buffer text", ); update_test_language_settings(cx, &|settings| { settings.defaults.formatter = Some(FormatterList::default()) }); let format = editor.update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }); format.await.unwrap(); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), buffer_text.to_string() + prettier_format_suffix + "\n" + prettier_format_suffix, "Autoformatting (via test prettier) was not applied to the original buffer text", ); } #[gpui::test] async fn test_document_format_with_prettier_explicit_language(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.settings"), Default::default()) .await; let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let ts_lang = Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..LanguageMatcher::default() }) .into(), prettier_parser_name: Some("typescript".to_string()), ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), )); language_registry.add(ts_lang.clone()); update_test_language_settings(cx, &|settings| { settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let test_plugin = "test_plugin"; let _ = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { prettier_plugins: vec![test_plugin], ..Default::default() }, ); let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.settings"), cx) }) .await .unwrap(); project.update(cx, |project, cx| { project.set_language_for_buffer(&buffer, ts_lang, cx) }); let buffer_text = "one\ntwo\nthree\n"; let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor.update_in(cx, |editor, window, cx| { editor.set_text(buffer_text, window, cx) }); editor .update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }) .unwrap() .await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), buffer_text.to_string() + prettier_format_suffix + "\ntypescript", "Test prettier formatting was not applied to the original buffer text", ); update_test_language_settings(cx, &|settings| { settings.defaults.formatter = Some(FormatterList::default()) }); let format = editor.update_in(cx, |editor, window, cx| { editor.perform_format( project.clone(), FormatTrigger::Manual, FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), window, cx, ) }); format.await.unwrap(); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), buffer_text.to_string() + prettier_format_suffix + "\ntypescript\n" + prettier_format_suffix + "\ntypescript", "Autoformatting (via test prettier) was not applied to the original buffer text", ); } #[gpui::test] async fn test_range_format_with_prettier(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.ts"), Default::default()).await; let project = Project::test(fs, [path!("/file.ts").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }) .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); update_test_language_settings(cx, &|settings| { settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let test_plugin = "test_plugin"; let _ = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { prettier_plugins: vec![test_plugin], ..Default::default() }, ); let prettier_range_format_suffix = project::TEST_PRETTIER_RANGE_FORMAT_SUFFIX; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.ts"), cx) }) .await .unwrap(); let buffer_text = "one\ntwo\nthree\nfour\nfive\n"; let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text(buffer_text, window, cx) }); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(3, 0)]) }); }); let format = editor .update_in(cx, |editor, window, cx| { editor.format_selections(&FormatSelections, window, cx) }) .unwrap(); format.await.unwrap(); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), format!("one\ntwo{prettier_range_format_suffix}\nthree\nfour\nfive\n"), "Range formatting (via test prettier) was not applied to the buffer text", ); } #[gpui::test] async fn test_range_format_with_prettier_explicit_language(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.settings"), Default::default()) .await; let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let ts_lang = Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..LanguageMatcher::default() }) .into(), prettier_parser_name: Some("typescript".to_string()), ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), )); language_registry.add(ts_lang.clone()); update_test_language_settings(cx, &|settings| { settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let test_plugin = "test_plugin"; let _ = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { prettier_plugins: vec![test_plugin], ..Default::default() }, ); let prettier_range_format_suffix = project::TEST_PRETTIER_RANGE_FORMAT_SUFFIX; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.settings"), cx) }) .await .unwrap(); project.update(cx, |project, cx| { project.set_language_for_buffer(&buffer, ts_lang, cx) }); let buffer_text = "one\ntwo\nthree\nfour\nfive\n"; let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { editor.set_text(buffer_text, window, cx) }); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(3, 0)]) }); }); let format = editor .update_in(cx, |editor, window, cx| { editor.format_selections(&FormatSelections, window, cx) }) .unwrap(); format.await.unwrap(); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), format!("one\ntwo{prettier_range_format_suffix}\ntypescript\nthree\nfour\nfive\n"), "Range formatting (via test prettier) was not applied with explicit language", ); } #[gpui::test] async fn test_addition_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let base_text = indoc! {r#" struct Row; struct Row1; struct Row2; struct Row4; struct Row5; struct Row6; struct Row8; struct Row9; struct Row10;"#}; // When addition hunks are not adjacent to carets, no hunk revert is performed assert_hunk_revert( indoc! {r#"struct Row; struct Row1; struct Row1.1; struct Row1.2; struct Row2;ˇ struct Row4; struct Row5; struct Row6; struct Row8; ˇstruct Row9; struct Row9.1; struct Row9.2; struct Row9.3; struct Row10;"#}, vec![DiffHunkStatusKind::Added, DiffHunkStatusKind::Added], indoc! {r#"struct Row; struct Row1; struct Row1.1; struct Row1.2; struct Row2;ˇ struct Row4; struct Row5; struct Row6; struct Row8; ˇstruct Row9; struct Row9.1; struct Row9.2; struct Row9.3; struct Row10;"#}, base_text, &mut cx, ); // Same for selections assert_hunk_revert( indoc! {r#"struct Row; struct Row1; struct Row2; struct Row2.1; struct Row2.2; «ˇ struct Row4; struct» Row5; «struct Row6; ˇ» struct Row9.1; struct Row9.2; struct Row9.3; struct Row8; struct Row9; struct Row10;"#}, vec![DiffHunkStatusKind::Added, DiffHunkStatusKind::Added], indoc! {r#"struct Row; struct Row1; struct Row2; struct Row2.1; struct Row2.2; «ˇ struct Row4; struct» Row5; «struct Row6; ˇ» struct Row9.1; struct Row9.2; struct Row9.3; struct Row8; struct Row9; struct Row10;"#}, base_text, &mut cx, ); // When carets and selections intersect the addition hunks, those are reverted. // Adjacent carets got merged. assert_hunk_revert( indoc! {r#"struct Row; ˇ// something on the top struct Row1; struct Row2; struct Roˇw3.1; struct Row2.2; struct Row2.3;ˇ struct Row4; struct ˇRow5.1; struct Row5.2; struct «Rowˇ»5.3; struct Row5; struct Row6; ˇ struct Row9.1; struct «Rowˇ»9.2; struct «ˇRow»9.3; struct Row8; struct Row9; «ˇ// something on bottom» struct Row10;"#}, vec![ DiffHunkStatusKind::Added, DiffHunkStatusKind::Added, DiffHunkStatusKind::Added, DiffHunkStatusKind::Added, DiffHunkStatusKind::Added, ], indoc! {r#"struct Row; ˇstruct Row1; struct Row2; ˇ struct Row4; ˇstruct Row5; struct Row6; ˇ ˇstruct Row8; struct Row9; ˇstruct Row10;"#}, base_text, &mut cx, ); } #[gpui::test] async fn test_modification_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let base_text = indoc! {r#" struct Row; struct Row1; struct Row2; struct Row4; struct Row5; struct Row6; struct Row8; struct Row9; struct Row10;"#}; // Modification hunks behave the same as the addition ones. assert_hunk_revert( indoc! {r#"struct Row; struct Row1; struct Row33; ˇ struct Row4; struct Row5; struct Row6; ˇ struct Row99; struct Row9; struct Row10;"#}, vec![DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified], indoc! {r#"struct Row; struct Row1; struct Row33; ˇ struct Row4; struct Row5; struct Row6; ˇ struct Row99; struct Row9; struct Row10;"#}, base_text, &mut cx, ); assert_hunk_revert( indoc! {r#"struct Row; struct Row1; struct Row33; «ˇ struct Row4; struct» Row5; «struct Row6; ˇ» struct Row99; struct Row9; struct Row10;"#}, vec![DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified], indoc! {r#"struct Row; struct Row1; struct Row33; «ˇ struct Row4; struct» Row5; «struct Row6; ˇ» struct Row99; struct Row9; struct Row10;"#}, base_text, &mut cx, ); assert_hunk_revert( indoc! {r#"ˇstruct Row1.1; struct Row1; «ˇstr»uct Row22; struct ˇRow44; struct Row5; struct «Rˇ»ow66;ˇ «struˇ»ct Row88; struct Row9; struct Row1011;ˇ"#}, vec![ DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified, ], indoc! {r#"struct Row; ˇstruct Row1; struct Row2; ˇ struct Row4; ˇstruct Row5; struct Row6; ˇ struct Row8; ˇstruct Row9; struct Row10;ˇ"#}, base_text, &mut cx, ); } #[gpui::test] async fn test_deleting_over_diff_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let base_text = indoc! {r#" one two three "#}; cx.set_head_text(base_text); cx.set_state("\nˇ\n"); cx.executor().run_until_parked(); cx.update_editor(|editor, _window, cx| { editor.expand_selected_diff_hunks(cx); }); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.backspace(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_state_with_diff( indoc! {r#" - two - threeˇ + "#} .to_string(), ); } #[gpui::test] async fn test_deletion_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let base_text = indoc! {r#"struct Row; struct Row1; struct Row2; struct Row4; struct Row5; struct Row6; struct Row8; struct Row9; struct Row10;"#}; // Deletion hunks trigger with carets on adjacent rows, so carets and selections have to stay farther to avoid the revert assert_hunk_revert( indoc! {r#"struct Row; struct Row2; ˇstruct Row4; struct Row5; struct Row6; ˇ struct Row8; struct Row10;"#}, vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], indoc! {r#"struct Row; struct Row2; ˇstruct Row4; struct Row5; struct Row6; ˇ struct Row8; struct Row10;"#}, base_text, &mut cx, ); assert_hunk_revert( indoc! {r#"struct Row; struct Row2; «ˇstruct Row4; struct» Row5; «struct Row6; ˇ» struct Row8; struct Row10;"#}, vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], indoc! {r#"struct Row; struct Row2; «ˇstruct Row4; struct» Row5; «struct Row6; ˇ» struct Row8; struct Row10;"#}, base_text, &mut cx, ); // Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections. assert_hunk_revert( indoc! {r#"struct Row; ˇstruct Row2; struct Row4; struct Row5; struct Row6; struct Row8;ˇ struct Row10;"#}, vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], indoc! {r#"struct Row; struct Row1; ˇstruct Row2; struct Row4; struct Row5; struct Row6; struct Row8;ˇ struct Row9; struct Row10;"#}, base_text, &mut cx, ); assert_hunk_revert( indoc! {r#"struct Row; struct Row2«ˇ; struct Row4; struct» Row5; «struct Row6; struct Row8;ˇ» struct Row10;"#}, vec![ DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted, ], indoc! {r#"struct Row; struct Row1; struct Row2«ˇ; struct Row4; struct» Row5; «struct Row6; struct Row8;ˇ» struct Row9; struct Row10;"#}, base_text, &mut cx, ); } #[gpui::test] async fn test_multibuffer_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); let base_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"; let base_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"; let base_text_3 = "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"; let text_1 = edit_first_char_of_every_line(base_text_1); let text_2 = edit_first_char_of_every_line(base_text_2); let text_3 = edit_first_char_of_every_line(base_text_3); let buffer_1 = cx.new(|cx| Buffer::local(text_1.clone(), cx)); let buffer_2 = cx.new(|cx| Buffer::local(text_2.clone(), cx)); let buffer_3 = cx.new(|cx| Buffer::local(text_3.clone(), cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer }); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [path!("/").as_ref()], cx).await; let (editor, cx) = cx .add_window_view(|window, cx| build_editor_with_project(project, multibuffer, window, cx)); editor.update_in(cx, |editor, _window, cx| { for (buffer, diff_base) in [ (buffer_1.clone(), base_text_1), (buffer_2.clone(), base_text_2), (buffer_3.clone(), base_text_3), ] { let diff = cx.new(|cx| { BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx) }); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); } }); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { assert_eq!(editor.display_text(cx), "\n\nXaaa\nXbbb\nXccc\n\nXfff\nXggg\n\nXjjj\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\n\n\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}"); editor.select_all(&SelectAll, window, cx); editor.git_restore(&Default::default(), window, cx); }); cx.executor().run_until_parked(); // When all ranges are selected, all buffer hunks are reverted. editor.update(cx, |editor, cx| { assert_eq!(editor.display_text(cx), "\n\naaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\n\n\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\n\n\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n\n\n"); }); buffer_1.update(cx, |buffer, _| { assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { assert_eq!(buffer.text(), base_text_2); }); buffer_3.update(cx, |buffer, _| { assert_eq!(buffer.text(), base_text_3); }); editor.update_in(cx, |editor, window, cx| { editor.undo(&Default::default(), window, cx); }); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(Point::new(0, 0)..Point::new(5, 0))); }); editor.git_restore(&Default::default(), window, cx); }); // Now, when all ranges selected belong to buffer_1, the revert should succeed, // but not affect buffer_2 and its related excerpts. editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), "\n\naaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\n\n\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\n\n\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}" ); }); buffer_1.update(cx, |buffer, _| { assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { assert_eq!( buffer.text(), "Xlll\nXmmm\nXnnn\nXooo\nXppp\nXqqq\nXrrr\nXsss\nXttt\nXuuu" ); }); buffer_3.update(cx, |buffer, _| { assert_eq!( buffer.text(), "Xvvv\nXwww\nXxxx\nXyyy\nXzzz\nX{{{\nX|||\nX}}}\nX~~~\nX\u{7f}\u{7f}\u{7f}" ); }); fn edit_first_char_of_every_line(text: &str) -> String { text.split('\n') .map(|line| format!("X{}", &line[1..])) .collect::>() .join("\n") } } #[gpui::test] async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); let cols = 4; let rows = 10; let sample_text_1 = sample_text(rows, cols, 'a'); assert_eq!( sample_text_1, "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" ); let sample_text_2 = sample_text(rows, cols, 'l'); assert_eq!( sample_text_2, "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" ); let sample_text_3 = sample_text(rows, cols, 'v'); assert_eq!( sample_text_3, "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}" ); let buffer_1 = cx.new(|cx| Buffer::local(sample_text_1.clone(), cx)); let buffer_2 = cx.new(|cx| Buffer::local(sample_text_2.clone(), cx)); let buffer_3 = cx.new(|cx| Buffer::local(sample_text_3.clone(), cx)); let multi_buffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(9, 4), ], 0, cx, ); multibuffer }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/a", json!({ "main.rs": sample_text_1, "other.rs": sample_text_2, "lib.rs": sample_text_3, }), ) .await; let project = Project::test(fs, ["/a".as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); let multibuffer_item_id = workspace.update_in(cx, |workspace, window, cx| { assert!( workspace.active_item(cx).is_none(), "active item should be None before the first item is added" ); workspace.add_item_to_active_pane( Box::new(multi_buffer_editor.clone()), None, true, window, cx, ); let active_item = workspace .active_item(cx) .expect("should have an active item after adding the multi buffer"); assert_eq!( active_item.buffer_kind(cx), ItemBufferKind::Multibuffer, "A multi buffer was expected to active after adding" ); active_item.item_id() }); cx.executor().run_until_parked(); multi_buffer_editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(1)..MultiBufferOffset(2))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); let first_item_id = workspace.update_in(cx, |workspace, window, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating into the 1st buffer"); let first_item_id = active_item.item_id(); assert_ne!( first_item_id, multibuffer_item_id, "Should navigate into the 1st buffer and activate it" ); assert_eq!( active_item.buffer_kind(cx), ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( active_item .act_as::(cx) .expect("should have navigated into an editor for the 1st buffer") .read(cx) .text(cx), sample_text_1 ); workspace .go_back(workspace.active_pane().downgrade(), window, cx) .detach_and_log_err(cx); first_item_id }); cx.executor().run_until_parked(); workspace.update_in(cx, |workspace, _, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating back"); assert_eq!( active_item.item_id(), multibuffer_item_id, "Should navigate back to the multi buffer" ); assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }); multi_buffer_editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(39)..MultiBufferOffset(40))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); let second_item_id = workspace.update_in(cx, |workspace, window, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating into the 2nd buffer"); let second_item_id = active_item.item_id(); assert_ne!( second_item_id, multibuffer_item_id, "Should navigate away from the multibuffer" ); assert_ne!( second_item_id, first_item_id, "Should navigate into the 2nd buffer and activate it" ); assert_eq!( active_item.buffer_kind(cx), ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( active_item .act_as::(cx) .expect("should have navigated into an editor") .read(cx) .text(cx), sample_text_2 ); workspace .go_back(workspace.active_pane().downgrade(), window, cx) .detach_and_log_err(cx); second_item_id }); cx.executor().run_until_parked(); workspace.update_in(cx, |workspace, _, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating back from the 2nd buffer"); assert_eq!( active_item.item_id(), multibuffer_item_id, "Should navigate back from the 2nd buffer to the multi buffer" ); assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }); multi_buffer_editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(MultiBufferOffset(70)..MultiBufferOffset(70))), ); editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); workspace.update_in(cx, |workspace, window, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating into the 3rd buffer"); let third_item_id = active_item.item_id(); assert_ne!( third_item_id, multibuffer_item_id, "Should navigate into the 3rd buffer and activate it" ); assert_ne!(third_item_id, first_item_id); assert_ne!(third_item_id, second_item_id); assert_eq!( active_item.buffer_kind(cx), ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( active_item .act_as::(cx) .expect("should have navigated into an editor") .read(cx) .text(cx), sample_text_3 ); workspace .go_back(workspace.active_pane().downgrade(), window, cx) .detach_and_log_err(cx); }); cx.executor().run_until_parked(); workspace.update_in(cx, |workspace, _, cx| { let active_item = workspace .active_item(cx) .expect("should have an active item after navigating back from the 3rd buffer"); assert_eq!( active_item.item_id(), multibuffer_item_id, "Should navigate back from the 3rd buffer to the multi buffer" ); assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }); } #[gpui::test] async fn test_merge_base_diff_hunks_are_read_only(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "file.rs": "worktree\n", }), ) .await; fs.set_head_and_index_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", "head\n".to_string())], ); fs.set_merge_base_content_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", "base\n".to_string())], ); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/file.rs"), cx) }) .await .unwrap(); let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multi_buffer, window, cx) }); cx.update(|_window, cx| { SettingsStore::update_global(cx, |settings, cx| { settings.update_user_settings(cx, |settings| { settings.git.get_or_insert_default().diff_base = Some(settings::GitDiffBaseSetting::DefaultBranch); }); }); }); cx.run_until_parked(); editor.read_with(cx, |editor, cx| { let diff = editor .buffer() .read(cx) .diff_for(buffer_id) .expect("buffer should have a display diff"); assert!(diff.read(cx).operations().is_none()); }); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.git_restore(&Default::default(), window, cx); editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); assert_eq!( editor.read_with(cx, |editor, cx| editor.text(cx)), "worktree\n" ); let index_contents = fs .with_git_state( std::path::Path::new(path!("/project/.git")), false, |state| state.index_contents.clone(), ) .unwrap(); assert_eq!( index_contents, [(::git::repository::repo_path("file.rs"), b"head\n".to_vec(),)] .into_iter() .collect::>() ); } #[gpui::test] async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod; const A: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(); cx.set_state( &r#" use some::modified; ˇ fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.go_to_next_hunk(&GoToHunk, window, cx); editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::modified; fn main() { - println!("hello"); + ˇ println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { for _ in 0..2 { editor.go_to_next_hunk(&GoToHunk, window, cx); editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); } }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - use some::mod; + ˇuse some::modified; fn main() { - println!("hello"); + println!("hello there"); + println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.go_to_next_hunk(&GoToHunk, window, cx); editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - use some::mod; + use some::modified; - const A: u32 = 42; ˇ fn main() { - println!("hello"); + println!("hello there"); + println!("around the"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.cancel(&Cancel, window, cx); }); cx.assert_state_with_diff( r#" use some::modified; ˇ fn main() { println!("hello there"); println!("around the"); println!("world"); } "# .unindent(), ); } #[gpui::test] async fn test_diff_base_change_with_expanded_diff_hunks( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; const C: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(); cx.set_state( &r#" use some::mod2; const A: u32 = 42; const C: u32 = 42; fn main(ˇ) { //println!("hello"); println!("world"); // // } "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - use some::mod1; use some::mod2; const A: u32 = 42; - const B: u32 = 42; const C: u32 = 42; fn main(ˇ) { - println!("hello"); + //println!("hello"); println!("world"); + // + // } "# .unindent(), ); cx.set_head_text("new diff base!"); executor.run_until_parked(); cx.assert_state_with_diff( r#" - new diff base! + use some::mod2; + + const A: u32 = 42; + const C: u32 = 42; + + fn main(ˇ) { + //println!("hello"); + + println!("world"); + // + // + } "# .unindent(), ); } #[gpui::test] async fn test_row_highlights_for_empty_conflict_side_after_edit( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { struct ReversedHighlight; init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( &r#" ˇ<<<<<<< HEAD ======= theirs >>>>>>> branch after "# .unindent(), ); executor.run_until_parked(); // Mirrors what `git_ui::conflict_view::update_conflict_highlighting` does // with the ranges produced by the conflict parser, and additionally // stores the inside-out range that empty sides used to produce. cx.update_editor(|editor, _, cx| { let buffer = editor.buffer().read(cx).as_singleton().unwrap(); let conflicts = project::ConflictSet::parse(&buffer.read(cx).text_snapshot()); assert_eq!(conflicts.conflicts.len(), 1); let conflict = &conflicts.conflicts[0]; let snapshot = editor.buffer().read(cx).snapshot(cx); let ours = snapshot .buffer_anchor_range_to_anchor_range(conflict.ours.clone()) .unwrap(); assert!(ours.start.cmp(&ours.end, &snapshot).is_le()); editor.highlight_rows::( ours, |cx| cx.theme().colors().version_control_conflict_marker_ours, RowHighlightOptions { include_gutter: true, ..Default::default() }, cx, ); let marker_start = Point::new(1, 0); editor.highlight_rows::( snapshot.anchor_after(marker_start)..snapshot.anchor_before(marker_start), |cx| cx.theme().colors().editor_highlighted_line_background, RowHighlightOptions::default(), cx, ); }); // The user types a resolution into the empty "ours" side, on the line // where the `=======` marker currently sits. cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); }); editor.handle_input("one\ntwo\nthree\nfour\nfive\nsix\n", window, cx); }); // Render every possible viewport, the same way `EditorElement::prepaint` // does, before the conflict set has been re-parsed. The typed rows carry // the "ours" highlight; the inside-out range never contributes. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx).display_snapshot; let ours_type_id = Some(TypeId::of::()); for start_row in 0..snapshot.max_point().row().0 { for end_row in start_row + 1..=snapshot.max_point().row().0 { let start = snapshot .buffer_snapshot() .anchor_before(Point::new(start_row, 0)); let end = snapshot .buffer_snapshot() .anchor_before(Point::new(end_row, 0)); let highlighted_rows = editor.highlighted_display_rows_in_range( start..end, DisplayRow(start_row)..DisplayRow(end_row), &snapshot, cx, ); let expected_rows = (start_row.max(1)..end_row.min(7)) .map(DisplayRow) .collect::>(); assert_eq!( highlighted_rows.keys().copied().collect::>(), expected_rows, "viewport {start_row}..{end_row}" ); assert!( highlighted_rows .values() .all(|highlight| highlight.type_id == ours_type_id), "viewport {start_row}..{end_row}" ); } } }); } #[gpui::test] async fn test_go_to_singleton_buffer_point_with_expanded_deleted_hunks( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" removed line 1 removed line 2 line 1 line 2 line 3 "# .unindent(); cx.set_state( &r#" ˇline 1 line 2 line 3 "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.go_to_singleton_buffer_point(Point::new(2, 0), window, cx); }); cx.assert_state_with_diff( r#" - removed line 1 - removed line 2 line 1 line 2 ˇline 3 "# .unindent(), ); } #[gpui::test] async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let file_1_old = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj"; let file_1_new = "aaa\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj"; let file_2_old = "lll\nmmm\nnnn\nooo\nppp\nqqq\nrrr\nsss\nttt\nuuu"; let file_2_new = "lll\nmmm\nNNN\nooo\nppp\nqqq\nrrr\nsss\nttt\nuuu"; let file_3_old = "111\n222\n333\n444\n555\n777\n888\n999\n000\n!!!"; let file_3_new = "111\n222\n333\n444\n555\n666\n777\n888\n999\n000\n!!!"; let buffer_1 = cx.new(|cx| Buffer::local(file_1_new.to_string(), cx)); let buffer_2 = cx.new(|cx| Buffer::local(file_2_new.to_string(), cx)); let buffer_3 = cx.new(|cx| Buffer::local(file_3_new.to_string(), cx)); let multi_buffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [ Point::new(0, 0)..Point::new(2, 3), Point::new(5, 0)..Point::new(6, 3), Point::new(9, 0)..Point::new(10, 3), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [ Point::new(0, 0)..Point::new(2, 3), Point::new(5, 0)..Point::new(6, 3), Point::new(9, 0)..Point::new(10, 3), ], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [ Point::new(0, 0)..Point::new(2, 3), Point::new(5, 0)..Point::new(6, 3), Point::new(9, 0)..Point::new(10, 3), ], 0, cx, ); assert_eq!(multibuffer.read(cx).excerpts().count(), 9); multibuffer }); let editor = cx.add_window(|window, cx| Editor::new(EditorMode::full(), multi_buffer, None, window, cx)); editor .update(cx, |editor, _window, cx| { for (buffer, diff_base) in [ (buffer_1.clone(), file_1_old), (buffer_2.clone(), file_2_old), (buffer_3.clone(), file_3_old), ] { let diff = cx.new(|cx| { BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx) }); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); } }) .unwrap(); let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); cx.assert_editor_state( &" ˇaaa ccc ddd ggg hhh lll mmm NNN qqq rrr uuu 111 222 333 666 777 000 !!!" .unindent(), ); cx.update_editor(|editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); }); cx.executor().run_until_parked(); cx.assert_state_with_diff( " «aaa - bbb ccc ddd ggg hhh lll mmm - nnn + NNN qqq rrr uuu 111 222 333 + 666 777 000 !!!ˇ»" .unindent(), ); } #[gpui::test] async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); let base = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\n"; let text = "aaa\nBBB\nBB2\nccc\nDDD\nEEE\nfff\nggg\nhhh\niii\n"; let buffer = cx.new(|cx| Buffer::local(text.to_string(), cx)); let multi_buffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer.clone(), [ Point::new(0, 0)..Point::new(1, 3), Point::new(4, 0)..Point::new(6, 3), Point::new(9, 0)..Point::new(9, 3), ], 0, cx, ); assert_eq!(multibuffer.read(cx).excerpts().count(), 3); multibuffer }); let editor = cx.add_window(|window, cx| Editor::new(EditorMode::full(), multi_buffer, None, window, cx)); editor .update(cx, |editor, _window, cx| { let diff = cx.new(|cx| { BufferDiff::new_with_base_text(base, &buffer.read(cx).text_snapshot(), cx) }); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)) }) .unwrap(); let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&Default::default(), window, cx) }); cx.executor().run_until_parked(); // When the start of a hunk coincides with the start of its excerpt, // the hunk is expanded. When the start of a hunk is earlier than // the start of its excerpt, the hunk is not expanded. cx.assert_state_with_diff( " ˇaaa - bbb + BBB - ddd - eee + DDD + EEE fff iii" .unindent(), ); } #[gpui::test] async fn test_edits_around_expanded_insertion_hunks( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod1; use some::mod2; const A: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(); executor.run_until_parked(); cx.set_state( &r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; const C: u32 = 42; ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| editor.handle_input("const D: u32 = 42;\n", window, cx)); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; + ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| editor.handle_input("const E: u32 = 42;\n", window, cx)); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; + ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.delete_line(&DeleteLine, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.move_up(&MoveUp, window, cx); editor.delete_line(&DeleteLine, window, cx); editor.move_up(&MoveUp, window, cx); editor.delete_line(&DeleteLine, window, cx); editor.move_up(&MoveUp, window, cx); editor.delete_line(&DeleteLine, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.select_up_by_lines(&SelectUpByLines { lines: 5 }, window, cx); editor.delete_line(&DeleteLine, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); } #[gpui::test] async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_head_text(indoc! { " one two three four five " }); cx.set_state(indoc! { " one ˇthree five "}); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " one - two ˇthree - four five "} .to_string(), ); cx.update_editor(|editor, window, cx| { editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " one ˇthree five "} .to_string(), ); cx.update_editor(|editor, window, cx| { editor.move_up(&MoveUp, window, cx); editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " ˇone - two three five "} .to_string(), ); cx.update_editor(|editor, window, cx| { editor.move_down(&MoveDown, window, cx); editor.move_down(&MoveDown, window, cx); editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " one - two ˇthree - four five "} .to_string(), ); cx.set_state(indoc! { " one ˇTWO three four five "}); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " one - two + ˇTWO three four five "} .to_string(), ); cx.update_editor(|editor, window, cx| { editor.move_up(&Default::default(), window, cx); editor.toggle_selected_diff_hunks(&Default::default(), window, cx); }); cx.assert_state_with_diff( indoc! { " one ˇTWO three four five "} .to_string(), ); } #[gpui::test] async fn test_toggling_adjacent_diff_hunks_2( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" lineA lineB lineC lineD "# .unindent(); cx.set_state( &r#" ˇlineA1 lineB lineD "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - lineA + ˇlineA1 lineB lineD "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.move_down(&MoveDown, window, cx); editor.move_right(&MoveRight, window, cx); editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - lineA + lineA1 lˇineB - lineC lineD "# .unindent(), ); } #[gpui::test] async fn test_edits_around_expanded_deletion_hunks( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; const C: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(); executor.run_until_parked(); cx.set_state( &r#" use some::mod1; use some::mod2; ˇconst B: u32 = 42; const C: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; ˇconst B: u32 = 42; const C: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.delete_line(&DeleteLine, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; - const B: u32 = 42; ˇconst C: u32 = 42; fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.delete_line(&DeleteLine, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; - const B: u32 = 42; - const C: u32 = 42; ˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("replacement", window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; - const B: u32 = 42; - const C: u32 = 42; - + replacementˇ fn main() { println!("hello"); println!("world"); } "# .unindent(), ); } #[gpui::test] async fn test_backspace_after_deletion_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let base_text = r#" one two three four five "# .unindent(); executor.run_until_parked(); cx.set_state( &r#" one two fˇour five "# .unindent(), ); cx.set_head_text(&base_text); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" one two - three fˇour five "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.backspace(&Backspace, window, cx); editor.backspace(&Backspace, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" one two - threeˇ - four + our five "# .unindent(), ); } #[gpui::test] async fn test_edit_after_expanded_modification_hunk( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; const C: u32 = 42; const D: u32 = 42; fn main() { println!("hello"); println!("world"); }"# .unindent(); cx.set_state( &r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; const C: u32 = 43ˇ const D: u32 = 42; fn main() { println!("hello"); println!("world"); }"# .unindent(), ); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; - const C: u32 = 42; + const C: u32 = 43ˇ const D: u32 = 42; fn main() { println!("hello"); println!("world"); }"# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.handle_input("\nnew_line\n", window, cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; const B: u32 = 42; - const C: u32 = 42; + const C: u32 = 43 + new_line + ˇ const D: u32 = 42; fn main() { println!("hello"); println!("world"); }"# .unindent(), ); } #[gpui::test] async fn test_stage_and_unstage_added_file_hunk( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, _, cx| { editor.set_expand_all_diff_hunks(cx); }); let working_copy = r#" ˇfn main() { println!("hello, world!"); } "# .unindent(); cx.set_state(&working_copy); executor.run_until_parked(); cx.assert_state_with_diff( r#" + ˇfn main() { + println!("hello, world!"); + } "# .unindent(), ); cx.assert_index_text(None); cx.update_editor(|editor, window, cx| { editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); }); executor.run_until_parked(); cx.assert_index_text(Some(&working_copy.replace("ˇ", ""))); cx.assert_state_with_diff( r#" + ˇfn main() { + println!("hello, world!"); + } "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); }); executor.run_until_parked(); cx.assert_index_text(None); } async fn setup_indent_guides_editor( text: &str, cx: &mut TestAppContext, ) -> (BufferId, EditorTestContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let buffer_id = cx.update_editor(|editor, window, cx| { editor.set_text(text, window, cx); editor .buffer() .read(cx) .as_singleton() .unwrap() .read(cx) .remote_id() }); (buffer_id, cx) } fn assert_indent_guides( range: Range, expected: Vec, active_indices: Option>, cx: &mut EditorTestContext, ) { let indent_guides = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx).display_snapshot; let mut indent_guides: Vec<_> = crate::indent_guides::indent_guides_in_range( editor, MultiBufferRow(range.start)..MultiBufferRow(range.end), true, &snapshot, cx, ); indent_guides.sort_by(|a, b| { a.depth.cmp(&b.depth).then( a.start_row .cmp(&b.start_row) .then(a.end_row.cmp(&b.end_row)), ) }); indent_guides }); if let Some(expected) = active_indices { let active_indices = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx).display_snapshot; editor.find_active_indent_guide_indices(&indent_guides, &snapshot, window, cx) }); assert_eq!( active_indices.unwrap().into_iter().collect::>(), expected, "Active indent guide indices do not match" ); } assert_eq!(indent_guides, expected, "Indent guides do not match"); } fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) -> IndentGuide { IndentGuide { buffer_id, start_row: MultiBufferRow(start_row), end_row: MultiBufferRow(end_row), depth, tab_size: 4, settings: IndentGuideSettings { enabled: true, line_width: 1, active_line_width: 1, coloring: IndentGuideColoring::default(), background_coloring: IndentGuideBackgroundColoring::default(), }, } } #[gpui::test] async fn test_indent_guide_single_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; }" .unindent(), cx, ) .await; assert_indent_guides(0..3, vec![indent_guide(buffer_id, 1, 1, 0)], None, &mut cx); } #[gpui::test] async fn test_indent_guide_simple_block(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let b = 2; }" .unindent(), cx, ) .await; assert_indent_guides(0..4, vec![indent_guide(buffer_id, 1, 2, 0)], None, &mut cx); } #[gpui::test] async fn test_indent_guide_nested(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; if a == 3 { let b = 2; } else { let c = 3; } }" .unindent(), cx, ) .await; assert_indent_guides( 0..8, vec![ indent_guide(buffer_id, 1, 6, 0), indent_guide(buffer_id, 3, 3, 1), indent_guide(buffer_id, 5, 5, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_tab(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let b = 2; let c = 3; }" .unindent(), cx, ) .await; assert_indent_guides( 0..5, vec![ indent_guide(buffer_id, 1, 3, 0), indent_guide(buffer_id, 2, 2, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_continues_on_empty_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let c = 3; }" .unindent(), cx, ) .await; assert_indent_guides(0..5, vec![indent_guide(buffer_id, 1, 3, 0)], None, &mut cx); } #[gpui::test] async fn test_indent_guide_complex(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let c = 3; if a == 3 { let b = 2; } else { let c = 3; } }" .unindent(), cx, ) .await; assert_indent_guides( 0..11, vec![ indent_guide(buffer_id, 1, 9, 0), indent_guide(buffer_id, 6, 6, 1), indent_guide(buffer_id, 8, 8, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_starts_off_screen(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let c = 3; if a == 3 { let b = 2; } else { let c = 3; } }" .unindent(), cx, ) .await; assert_indent_guides( 1..11, vec![ indent_guide(buffer_id, 1, 9, 0), indent_guide(buffer_id, 6, 6, 1), indent_guide(buffer_id, 8, 8, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_ends_off_screen(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let c = 3; if a == 3 { let b = 2; } else { let c = 3; } }" .unindent(), cx, ) .await; assert_indent_guides( 1..10, vec![ indent_guide(buffer_id, 1, 9, 0), indent_guide(buffer_id, 6, 6, 1), indent_guide(buffer_id, 8, 8, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_with_folds(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { if a { b( c, d, ) } else { e( f ) } }" .unindent(), cx, ) .await; assert_indent_guides( 0..11, vec![ indent_guide(buffer_id, 1, 10, 0), indent_guide(buffer_id, 2, 5, 1), indent_guide(buffer_id, 7, 9, 1), indent_guide(buffer_id, 3, 4, 2), indent_guide(buffer_id, 8, 8, 2), ], None, &mut cx, ); cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(2), window, cx); assert_eq!( editor.display_text(cx), " fn main() { if a { b(⋯) } else { e( f ) } }" .unindent() ); }); assert_indent_guides( 0..11, vec![ indent_guide(buffer_id, 1, 10, 0), indent_guide(buffer_id, 2, 5, 1), indent_guide(buffer_id, 7, 9, 1), indent_guide(buffer_id, 8, 8, 2), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_without_brackets(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" block1 block2 block3 block4 block2 block1 block1" .unindent(), cx, ) .await; assert_indent_guides( 1..10, vec![ indent_guide(buffer_id, 1, 4, 0), indent_guide(buffer_id, 2, 3, 1), indent_guide(buffer_id, 3, 3, 2), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_ends_before_empty_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" block1 block2 block3 block1 block1" .unindent(), cx, ) .await; assert_indent_guides( 0..6, vec![ indent_guide(buffer_id, 1, 2, 0), indent_guide(buffer_id, 2, 2, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_ignored_only_whitespace_lines(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" function component() { \treturn ( \t\t\t \t\t

\t\t\t \t\t
\t) }" .unindent(), cx, ) .await; assert_indent_guides( 0..8, vec![ indent_guide(buffer_id, 1, 6, 0), indent_guide(buffer_id, 2, 5, 1), indent_guide(buffer_id, 4, 4, 2), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_fallback_to_next_non_entirely_whitespace_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" function component() { \treturn ( \t \t\t
\t\t\t \t\t
\t) }" .unindent(), cx, ) .await; assert_indent_guides( 0..8, vec![ indent_guide(buffer_id, 1, 6, 0), indent_guide(buffer_id, 2, 5, 1), indent_guide(buffer_id, 4, 4, 2), ], None, &mut cx, ); } #[gpui::test] async fn test_indent_guide_continuing_off_screen(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" block1 block2 " .unindent(), cx, ) .await; assert_indent_guides(0..1, vec![indent_guide(buffer_id, 1, 1, 0)], None, &mut cx); } #[gpui::test] async fn test_indent_guide_tabs(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" def a: \tb = 3 \tif True: \t\tc = 4 \t\td = 5 \tprint(b) " .unindent(), cx, ) .await; assert_indent_guides( 0..6, vec![ indent_guide(buffer_id, 1, 5, 0), indent_guide(buffer_id, 3, 4, 1), ], None, &mut cx, ); } #[gpui::test] async fn test_active_indent_guide_single_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; }" .unindent(), cx, ) .await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) }); }); assert_indent_guides( 0..3, vec![indent_guide(buffer_id, 1, 1, 0)], Some(vec![0]), &mut cx, ); } #[gpui::test] async fn test_active_indent_guide_respect_indented_range(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { if 1 == 2 { let a = 1; } }" .unindent(), cx, ) .await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) }); }); cx.run_until_parked(); assert_indent_guides( 0..4, vec![ indent_guide(buffer_id, 1, 3, 0), indent_guide(buffer_id, 2, 2, 1), ], Some(vec![1]), &mut cx, ); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) }); }); cx.run_until_parked(); assert_indent_guides( 0..4, vec![ indent_guide(buffer_id, 1, 3, 0), indent_guide(buffer_id, 2, 2, 1), ], Some(vec![1]), &mut cx, ); cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(3, 0)..Point::new(3, 0)]) }); }); cx.run_until_parked(); assert_indent_guides( 0..4, vec![ indent_guide(buffer_id, 1, 3, 0), indent_guide(buffer_id, 2, 2, 1), ], Some(vec![0]), &mut cx, ); } #[gpui::test] async fn test_active_indent_guide_empty_line(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" fn main() { let a = 1; let b = 2; }" .unindent(), cx, ) .await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) }); }); assert_indent_guides( 0..5, vec![indent_guide(buffer_id, 1, 3, 0)], Some(vec![0]), &mut cx, ); } #[gpui::test] async fn test_active_indent_guide_non_matching_indent(cx: &mut TestAppContext) { let (buffer_id, mut cx) = setup_indent_guides_editor( &" def m: a = 1 pass" .unindent(), cx, ) .await; cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) }); }); assert_indent_guides( 0..3, vec![indent_guide(buffer_id, 1, 2, 0)], Some(vec![0]), &mut cx, ); } #[gpui::test] async fn test_indent_guide_with_expanded_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let text = indoc! { " impl A { fn b() { 0; 3; 5; 6; 7; } } " }; let base_text = indoc! { " impl A { fn b() { 0; 1; 2; 3; 4; } fn c() { 5; 6; 7; } } " }; cx.update_editor(|editor, window, cx| { editor.set_text(text, window, cx); editor.buffer().update(cx, |multibuffer, cx| { let buffer = multibuffer.as_singleton().unwrap(); let diff = cx.new(|cx| { BufferDiff::new_with_base_text(base_text, &buffer.read(cx).text_snapshot(), cx) }); multibuffer.set_all_diff_hunks_expanded(cx); multibuffer.add_diff(diff, cx); buffer.read(cx).remote_id() }) }); cx.run_until_parked(); cx.assert_state_with_diff( indoc! { " impl A { fn b() { 0; - 1; - 2; 3; - 4; - } - fn c() { 5; 6; 7; } } ˇ" } .to_string(), ); let mut actual_guides = cx.update_editor(|editor, window, cx| { editor .snapshot(window, cx) .buffer_snapshot() .indent_guides_in_range(Anchor::Min..Anchor::Max, false, cx) .map(|guide| (guide.start_row..=guide.end_row, guide.depth)) .collect::>() }); actual_guides.sort_by_key(|item| (*item.0.start(), item.1)); assert_eq!( actual_guides, vec![ (MultiBufferRow(1)..=MultiBufferRow(12), 0), (MultiBufferRow(2)..=MultiBufferRow(6), 1), (MultiBufferRow(9)..=MultiBufferRow(11), 1), ] ); } #[gpui::test] async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" a b c "# .unindent(); cx.set_state( &r#" ˇA b C "# .unindent(), ); cx.set_head_text(&diff_base); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); let both_hunks_expanded = r#" - a + ˇA b - c + C "# .unindent(); cx.assert_state_with_diff(both_hunks_expanded.clone()); let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) .collect::>(); let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); hunks .into_iter() .map(|hunk| { multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.start) .unwrap() ..multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.end) .unwrap() }) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); }); executor.run_until_parked(); let second_hunk_expanded = r#" ˇA b - c + C "# .unindent(); cx.assert_state_with_diff(second_hunk_expanded); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); }); executor.run_until_parked(); cx.assert_state_with_diff(both_hunks_expanded.clone()); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); }); executor.run_until_parked(); let first_hunk_expanded = r#" - a + ˇA b C "# .unindent(); cx.assert_state_with_diff(first_hunk_expanded); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); }); executor.run_until_parked(); cx.assert_state_with_diff(both_hunks_expanded); cx.set_state( &r#" ˇA b "# .unindent(), ); cx.run_until_parked(); // TODO this cursor position seems bad cx.assert_state_with_diff( r#" - ˇa + A b "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); cx.assert_state_with_diff( r#" - ˇa + A b - c "# .unindent(), ); let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) .collect::>(); let multibuffer_snapshot = snapshot.buffer_snapshot(); hunks .into_iter() .map(|hunk| { multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.start) .unwrap() ..multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.end) .unwrap() }) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); }); executor.run_until_parked(); cx.assert_state_with_diff( r#" - ˇa + A b "# .unindent(), ); } #[gpui::test] async fn test_toggle_deletion_hunk_at_start_of_file( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" a b c "# .unindent(); cx.set_state( &r#" ˇb c "# .unindent(), ); cx.set_head_text(&diff_base); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); let hunk_expanded = r#" - a ˇb c "# .unindent(); cx.assert_state_with_diff(hunk_expanded.clone()); let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) .collect::>(); let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); hunks .into_iter() .map(|hunk| { multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.start) .unwrap() ..multibuffer_snapshot .anchor_in_excerpt(hunk.buffer_range.end) .unwrap() }) .collect::>() }); assert_eq!(hunk_ranges.len(), 1); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); }); executor.run_until_parked(); let hunk_collapsed = r#" ˇb c "# .unindent(); cx.assert_state_with_diff(hunk_collapsed); cx.update_editor(|editor, _, cx| { editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); }); executor.run_until_parked(); cx.assert_state_with_diff(hunk_expanded); } #[gpui::test] async fn test_select_smaller_syntax_node_after_diff_hunk_collapse( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.set_state( &r#" fn main() { let x = ˇ1; } "# .unindent(), ); let diff_base = r#" fn removed_one() { println!("this function was deleted"); } fn removed_two() { println!("this function was also deleted"); } fn main() { let x = 1; } "# .unindent(); cx.set_head_text(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); }); cx.update_editor(|editor, window, cx| { editor.collapse_all_diff_hunks(&CollapseAllDiffHunks, window, cx); }); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); }); } #[gpui::test] async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇnew\nsecond\nthird\n"); cx.set_head_text("old\nsecond\nthird\n"); cx.update_editor(|editor, window, cx| { editor.scroll(gpui::Point { x: 0., y: 0. }, window, cx); }); executor.run_until_parked(); assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); let hunks = editor .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) .collect::>(); assert_eq!(hunks.len(), 1); let hunk_range = multibuffer_snapshot .anchor_in_excerpt(hunks[0].buffer_range.start) .unwrap() ..multibuffer_snapshot .anchor_in_excerpt(hunks[0].buffer_range.end) .unwrap(); editor.toggle_single_diff_hunk(hunk_range, cx) }); executor.run_until_parked(); cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); // Keep the editor scrolled to the top so the full hunk remains visible. assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); } #[gpui::test] async fn test_display_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), json!({ ".git": {}, "file-1": "ONE\n", "file-2": "TWO\n", "file-3": "THREE\n", }), ) .await; fs.set_head_for_repo( path!("/test/.git").as_ref(), &[ ("file-1", "one\n".into()), ("file-2", "two\n".into()), ("file-3", "three\n".into()), ], "deadbeef", ); let project = Project::test(fs, [path!("/test").as_ref()], cx).await; let mut buffers = vec![]; for i in 1..=3 { let buffer = project .update(cx, |project, cx| { let path = format!(path!("/test/file-{}"), i); project.open_local_buffer(path, cx) }) .await .unwrap(); buffers.push(buffer); } let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); multibuffer.set_all_diff_hunks_expanded(cx); for buffer in &buffers { let snapshot = buffer.read(cx).snapshot(); multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(0, buffer.read(cx).file().unwrap().path().clone()), buffer.clone(), vec![Point::zero()..snapshot.max_point()], 2, cx, ); } multibuffer }); let editor = cx.add_window(|window, cx| { Editor::new(EditorMode::full(), multibuffer, Some(project), window, cx) }); cx.run_until_parked(); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); let hunks = snapshot .display_diff_hunks_for_rows(DisplayRow(0)..DisplayRow(u32::MAX), &Default::default()) .map(|hunk| match hunk { DisplayDiffHunk::Unfolded { display_row_range, .. } => display_row_range, DisplayDiffHunk::Folded { .. } => unreachable!(), }) .collect::>(); assert_eq!( hunks, [ DisplayRow(2)..DisplayRow(4), DisplayRow(7)..DisplayRow(9), DisplayRow(12)..DisplayRow(14), ] ); } #[gpui::test] async fn test_partially_staged_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_head_text(indoc! { " one two three four five " }); cx.set_index_text(indoc! { " one two three four five " }); cx.set_state(indoc! {" one TWO ˇTHREE FOUR five "}); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_index_text(Some(indoc! {" one TWO THREE FOUR five "})); cx.set_state(indoc! { " one TWO ˇTHREE-HUNDRED FOUR five "}); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) .collect::>(); assert_eq!(hunks.len(), 1); assert_eq!( hunks[0].status(), DiffHunkStatus { kind: DiffHunkStatusKind::Modified, secondary: DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk } ); editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_index_text(Some(indoc! {" one TWO THREE-HUNDRED FOUR five "})); } #[gpui::test] fn test_crease_insertion_and_rendering(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); build_editor(buffer, window, cx) }); let render_args = Arc::new(Mutex::new(None)); let snapshot = editor .update(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(2, 6)); struct RenderArgs { row: MultiBufferRow, folded: bool, callback: Arc, } let crease = Crease::inline( range, FoldPlaceholder::test(), { let toggle_callback = render_args.clone(); move |row, folded, callback, _window, _cx| { *toggle_callback.lock() = Some(RenderArgs { row, folded, callback, }); div() } }, |_row, _folded, _window, _cx| div(), ); editor.insert_creases(Some(crease), cx); let snapshot = editor.snapshot(window, cx); let _div = snapshot.render_crease_toggle(MultiBufferRow(1), false, cx.entity(), window, cx); snapshot }) .unwrap(); let render_args = render_args.lock().take().unwrap(); assert_eq!(render_args.row, MultiBufferRow(1)); assert!(!render_args.folded); assert!(!snapshot.is_line_folded(MultiBufferRow(1))); cx.update_window(*editor, |_, window, cx| { (render_args.callback)(true, window, cx) }) .unwrap(); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); assert!(snapshot.is_line_folded(MultiBufferRow(1))); cx.update_window(*editor, |_, window, cx| { (render_args.callback)(false, window, cx) }) .unwrap(); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); assert!(!snapshot.is_line_folded(MultiBufferRow(1))); } #[gpui::test] async fn test_input_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( &r#"ˇone two three fourˇ five siˇx"# .unindent(), ); cx.dispatch_action(HandleInput(String::new())); cx.assert_editor_state( &r#"ˇone two three fourˇ five siˇx"# .unindent(), ); cx.dispatch_action(HandleInput("AAAA".to_string())); cx.assert_editor_state( &r#"AAAAˇone two three fourAAAAˇ five siAAAAˇx"# .unindent(), ); } #[gpui::test] async fn test_scroll_cursor_center_top_bottom(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( r#"let foo = 1; let foo = 2; let foo = 3; let fooˇ = 4; let foo = 5; let foo = 6; let foo = 7; let foo = 8; let foo = 9; let foo = 10; let foo = 11; let foo = 12; let foo = 13; let foo = 14; let foo = 15;"#, ); cx.update_editor(|e, window, cx| { assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Center, "Default next scroll direction is center", ); e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Top, "After center, next scroll direction should be top", ); e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Bottom, "After top, next scroll direction should be bottom", ); e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Center, "After bottom, scrolling should start over", ); e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Top, "Scrolling continues if retriggered fast enough" ); }); cx.executor() .advance_clock(SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT + Duration::from_millis(200)); cx.executor().run_until_parked(); cx.update_editor(|e, _, _| { assert_eq!( e.next_scroll_position, NextScrollCursorCenterTopBottom::Center, "If scrolling is not triggered fast enough, it should reset" ); }); } #[gpui::test] async fn test_goto_definition_reveals_existing_editor_in_other_pane(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.workspace.reveal_if_open = Some(true); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; cx.set_state("fn main() { ˇtarget(); }"); let origin_path = cx.update_editor(|editor, _, cx| editor.active_project_path(cx).unwrap()); let (fake_fs, worktree_id) = cx.update_workspace(|workspace, _, cx| { let project = workspace.project(); let fake_fs = project.read(cx).fs().as_fake(); let worktree_id = project.read(cx).worktrees(cx).next().unwrap().read(cx).id(); (fake_fs, worktree_id) }); let target_abs_path = EditorLspTestContext::root_path() .join("dir") .join("definition.rs"); fake_fs .insert_file(&target_abs_path, b"fn target() {}\n".to_vec()) .await; cx.run_until_parked(); let (left_pane, right_pane) = cx.update_workspace(|workspace, window, cx| { let left_pane = workspace.active_pane().clone(); let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, window, cx); (left_pane, right_pane) }); let origin_editor = cx .update_workspace(|workspace, window, cx| { workspace.open_path(origin_path, Some(right_pane.downgrade()), true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); let target_editor = cx .update_workspace(|workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("dir/definition.rs")), Some(left_pane.downgrade()), false, window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); let target_buffer = target_editor.read_with(&cx.cx.cx, |editor, cx| { editor.buffer().read(cx).as_singleton().unwrap() }); cx.update_workspace(|workspace, window, cx| { assert!(workspace.activate_item(&origin_editor, true, true, window, cx)); }); cx.run_until_parked(); cx.update_workspace(|workspace, _, _| { assert_eq!(workspace.active_pane(), &right_pane); }); let target_uri = lsp::Uri::from_file_path(target_abs_path).unwrap(); let _go_to_definition = cx .lsp .set_request_handler::(move |_, _| { let target_uri = target_uri.clone(); async move { Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { uri: target_uri, range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 9)), }))) } }); let navigated = origin_editor .update_in(&mut cx.cx.cx, |editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definition"); assert_eq!(navigated, Navigated::Yes); cx.run_until_parked(); cx.update_workspace(|workspace, _, cx| { assert_eq!(workspace.active_pane(), &left_pane); assert_eq!( left_pane .read(cx) .active_item() .unwrap() .downcast::() .unwrap(), target_editor ); assert_eq!( right_pane .read(cx) .active_item() .unwrap() .downcast::() .unwrap(), origin_editor ); assert_eq!( workspace .items_of_type::(cx) .filter(|editor| { editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(&target_buffer) }) .count(), 1 ); }); } #[gpui::test] async fn test_goto_definition_with_find_all_references_fallback(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; let set_up_lsp_handlers = |empty_go_to_definition: bool, cx: &mut EditorLspTestContext| { let go_to_definition = cx .lsp .set_request_handler::( move |params, _| async move { if empty_go_to_definition { Ok(None) } else { Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { uri: params.text_document_position_params.text_document.uri, range: lsp::Range::new( lsp::Position::new(4, 3), lsp::Position::new(4, 6), ), }))) } }, ); let references = cx .lsp .set_request_handler::(move |params, _| async move { Ok(Some(vec![lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 11)), }])) }); (go_to_definition, references) }; cx.set_state( &r#"fn one() { let mut a = ˇtwo(); } fn two() {}"# .unindent(), ); set_up_lsp_handlers(false, &mut cx); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definition"); assert_eq!( navigated, Navigated::Yes, "Should have navigated to definition from the GetDefinition response" ); cx.assert_editor_state( &r#"fn one() { let mut a = two(); } fn «twoˇ»() {}"# .unindent(), ); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, test_editor_cx| { assert_eq!( editors.len(), 1, "Initially, only one, test, editor should be open in the workspace" ); assert_eq!( test_editor_cx.entity(), editors.last().expect("Asserted len is 1").clone() ); }); set_up_lsp_handlers(true, &mut cx); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to lookup references"); assert_eq!( navigated, Navigated::Yes, "Should have navigated to references as a fallback after empty GoToDefinition response" ); // We should not change the selections in the existing file, // if opening another milti buffer with the references cx.assert_editor_state( &r#"fn one() { let mut a = two(); } fn «twoˇ»() {}"# .unindent(), ); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, test_editor_cx| { assert_eq!( editors.len(), 2, "After falling back to references search, we open a new editor with the results" ); let references_fallback_text = editors .into_iter() .find(|new_editor| *new_editor != test_editor_cx.entity()) .expect("Should have one non-test editor now") .read(test_editor_cx) .text(test_editor_cx); assert_eq!( references_fallback_text, "fn one() {\n let mut a = two();\n}", "Should use the range from the references response and not the GoToDefinition one" ); }); } /// End-to-end regression test for ZED-79W ("cannot summarize backward"): /// language servers can return a location whose range is reversed (start /// after end). `GetReferences::response_from_lsp` used to convert the /// endpoints individually, bypassing `range_from_lsp`'s normalization, so /// the reversed range reached excerpt construction intact, and when the /// reversal exceeded twice the excerpt context line count, the results /// multibuffer built an excerpt whose context anchors resolve backward, /// panicking the rope layer. #[gpui::test] async fn test_find_all_references_with_reversed_server_range(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; cx.set_state( &r#"fn one() { let mut a = ˇtwo(); } fn two() {} fn three() { two(); two(); two(); }"# .unindent(), ); cx.lsp .set_request_handler::(move |params, _| async move { Ok(Some(vec![ lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range: lsp::Range::new(lsp::Position::new(1, 16), lsp::Position::new(1, 19)), }, // A reversed range, as returned by some language servers. lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(9, 8), lsp::Position::new(1, 16)), }, ])) }); let navigated = cx .update_editor(|editor, window, cx| { editor.find_all_references(&FindAllReferences::default(), window, cx) }) .expect("should have spawned a references request") .await .expect("references request should succeed"); assert_eq!(navigated, Navigated::Yes); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, test_editor_cx| { assert_eq!( editors.len(), 2, "references should open in a new multibuffer editor" ); let references_text = editors .into_iter() .find(|new_editor| *new_editor != test_editor_cx.entity()) .expect("should have one non-test editor") .read(test_editor_cx) .text(test_editor_cx); assert!( references_text.contains("fn three()"), "the reversed range's rows should be excerpted, got: {references_text:?}" ); }); } #[gpui::test] async fn test_goto_definition_no_fallback(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { let mut editor_settings = EditorSettings::get_global(cx).clone(); editor_settings.go_to_definition_fallback = GoToDefinitionFallback::None; EditorSettings::override_global(editor_settings, cx); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; let original_state = r#"fn one() { let mut a = ˇtwo(); } fn two() {}"# .unindent(); cx.set_state(&original_state); let mut go_to_definition = cx .lsp .set_request_handler::( move |_, _| async move { Ok(None) }, ); let _references = cx .lsp .set_request_handler::(move |_, _| async move { panic!("Should not call for references with no go to definition fallback") }); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to lookup references"); go_to_definition .next() .await .expect("Should have called the go_to_definition handler"); assert_eq!( navigated, Navigated::No, "Should have navigated to references as a fallback after empty GoToDefinition response" ); cx.assert_editor_state(&original_state); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, _| { assert_eq!( editors.len(), 1, "After unsuccessful fallback, no other editor should have been opened" ); }); } #[gpui::test] async fn test_goto_definition_close_ranges_open_singleton(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; // File content: 10 lines with functions defined on lines 3, 5, and 7 (0-indexed). // With the default excerpt_context_lines of 2, ranges that are within // 2 * 2 = 4 rows of each other should be grouped into one excerpt. cx.set_state( &r#"fn caller() { let _ = ˇtarget(); } fn target_a() {} fn target_b() {} fn target_c() {} "# .unindent(), ); // Return two definitions that are close together (lines 3 and 5, gap of 2 rows) cx.set_request_handler::(move |url, _, _| async move { Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ lsp::Location { uri: url.clone(), range: lsp::Range::new(lsp::Position::new(3, 3), lsp::Position::new(3, 11)), }, lsp::Location { uri: url, range: lsp::Range::new(lsp::Position::new(5, 3), lsp::Position::new(5, 11)), }, ]))) }); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definitions"); assert_eq!(navigated, Navigated::Yes); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, _| { assert_eq!( editors.len(), 1, "Close ranges should navigate in-place without opening a new editor" ); }); // Both target ranges should be selected cx.assert_editor_state( &r#"fn caller() { let _ = target(); } fn «target_aˇ»() {} fn «target_bˇ»() {} fn target_c() {} "# .unindent(), ); } #[gpui::test] async fn test_goto_definition_far_ranges_open_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; // Create a file with definitions far apart (more than 2 * excerpt_context_lines rows). cx.set_state( &r#"fn caller() { let _ = ˇtarget(); } fn target_a() {} fn target_b() {} "# .unindent(), ); // Return two definitions that are far apart (lines 3 and 19, gap of 16 rows) cx.set_request_handler::(move |url, _, _| async move { Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ lsp::Location { uri: url.clone(), range: lsp::Range::new(lsp::Position::new(3, 3), lsp::Position::new(3, 11)), }, lsp::Location { uri: url, range: lsp::Range::new(lsp::Position::new(19, 3), lsp::Position::new(19, 11)), }, ]))) }); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definitions"); assert_eq!(navigated, Navigated::Yes); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, test_editor_cx| { assert_eq!( editors.len(), 2, "Far apart ranges should open a new multibuffer editor" ); let multibuffer_editor = editors .into_iter() .find(|editor| *editor != test_editor_cx.entity()) .expect("Should have a multibuffer editor"); let multibuffer_text = multibuffer_editor.read(test_editor_cx).text(test_editor_cx); assert!( multibuffer_text.contains("target_a"), "Multibuffer should contain the first definition" ); assert!( multibuffer_text.contains("target_b"), "Multibuffer should contain the second definition" ); }); } #[gpui::test] async fn test_goto_definition_contained_ranges(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; // The LSP returns two single-line definitions on the same row where one // range contains the other. Both are on the same line so the // `fits_in_one_excerpt` check won't underflow, and the code reaches // `change_selections`. cx.set_state( &r#"fn caller() { let _ = ˇtarget(); } fn target_outer() { fn target_inner() {} } "# .unindent(), ); // Return two definitions on the same line: an outer range covering the // whole line and an inner range for just the inner function name. cx.set_request_handler::(move |url, _, _| async move { Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ // Inner range: just "target_inner" (cols 23..35) lsp::Location { uri: url.clone(), range: lsp::Range::new(lsp::Position::new(3, 23), lsp::Position::new(3, 35)), }, // Outer range: the whole line (cols 0..48) lsp::Location { uri: url, range: lsp::Range::new(lsp::Position::new(3, 0), lsp::Position::new(3, 48)), }, ]))) }); let navigated = cx .update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definitions"); assert_eq!(navigated, Navigated::Yes); } #[gpui::test] async fn test_goto_definition_preserve_scroll_strategy(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.go_to_definition_scroll_strategy = Some(GoToDefinitionScrollStrategy::Preserve); settings.vertical_scroll_margin = Some(0.0); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; let window = cx.window; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); cx.simulate_window_resize(window, size(px(1000.), 8. * line_height)); // Build a buffer where `target` is defined on row 10 and called from // row 20, with the cursor placed on the call site. let buffer = indoc! { " // 0 // 1 // 2 // 3 // 4 // 5 // 6 // 7 // 8 // 9 fn target() // 10 // 11 // 12 // 13 // 14 // 15 // 16 // 17 // 18 // 19 fn caller() { ˇtarget(); } // 20 // 21 // 22 // 23 // 24 // 25 // 26 // 27 // 28 // 29 // 30 "}; // Mock the response from the LSP server when requesting to go to a // definition so as to always jump to the `target` function. cx.set_request_handler::(|url, _, _| async move { Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { uri: url.clone(), range: lsp::Range::new(lsp::Position::new(10, 3), lsp::Position::new(10, 9)), }))) }); let caller_row = 20.0; let target_row = 10.0; let offset = 1.5; let center_offset = cx.update_editor(|editor, _, _| { editor .visible_line_count() .map(|count| ((count - 1.0) / 2.0).floor()) .expect("Visible line count should be available") }); // When the cursor is visible inside the viewport, going to a definition // should preserve that same offset value. // In this case, with the cursor set at row 20 and the scroll position set // to 18.5 (20 - 1.5), when going to the definition of `target` in row 10, // the scroll position should end up at 8.5 (10 - 1.5), so as to preserve // that same offset of 1.5. cx.set_state(&buffer); cx.update_editor(|editor, window, cx| { editor.set_scroll_position(gpui::Point::new(0.0, caller_row - offset), window, cx); }); cx.update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definition"); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0.0, target_row - offset), ); }); // In the case where the cursor ends up outside of the visible viewport, the // scroll position's offset should be ignored and the center of the viewport // should be used instead. // Since the cursor is jumping to row 10, the scroll position's y coordinate // should end up at 10 minus the offset from the center of the viewport. cx.set_state(&buffer); cx.update_editor(|editor, window, cx| { editor.set_scroll_position(gpui::Point::new(0.0, 0.0), window, cx); let snapshot = editor.display_snapshot(cx); let cursor_row = editor .selections .newest_display(&snapshot) .start .row() .as_f64(); let visible_lines = editor .visible_line_count() .expect("Visible line count should be available"); assert!(cursor_row >= visible_lines, "Cursor should be offscreen"); }); cx.update_editor(|editor, window, cx| { editor.go_to_definition(&GoToDefinition::default(), window, cx) }) .await .expect("Failed to navigate to definition"); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0.0, (target_row - center_offset).max(0.0)), ); }); } #[gpui::test] async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; cx.set_state( &r#" fn one() { let mut a = two(); } fn ˇtwo() {}"# .unindent(), ); cx.lsp .set_request_handler::(move |params, _| async move { Ok(Some(vec![ lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), }, lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(4, 4), lsp::Position::new(4, 7)), }, ])) }); let navigated = cx .update_editor(|editor, window, cx| { editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await .expect("Failed to navigate to references"); assert_eq!( navigated, Navigated::Yes, "Should have navigated to references from the FindAllReferences response" ); cx.assert_editor_state( &r#"fn one() { let mut a = two(); } fn ˇtwo() {}"# .unindent(), ); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, _| { assert_eq!(editors.len(), 2, "We should have opened a new multibuffer"); }); cx.set_state( &r#"fn one() { let mut a = ˇtwo(); } fn two() {}"# .unindent(), ); let navigated = cx .update_editor(|editor, window, cx| { editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await .expect("Failed to navigate to references"); assert_eq!( navigated, Navigated::Yes, "Should have navigated to references from the FindAllReferences response" ); cx.assert_editor_state( &r#"fn one() { let mut a = ˇtwo(); } fn two() {}"# .unindent(), ); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, _| { assert_eq!( editors.len(), 2, "should have re-used the previous multibuffer" ); }); cx.set_state( &r#"fn one() { let mut a = ˇtwo(); } fn three() {} fn two() {}"# .unindent(), ); cx.lsp .set_request_handler::(move |params, _| async move { Ok(Some(vec![ lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), }, lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(5, 4), lsp::Position::new(5, 7)), }, ])) }); let navigated = cx .update_editor(|editor, window, cx| { editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await .expect("Failed to navigate to references"); assert_eq!( navigated, Navigated::Yes, "Should have navigated to references from the FindAllReferences response" ); cx.assert_editor_state( &r#"fn one() { let mut a = ˇtwo(); } fn three() {} fn two() {}"# .unindent(), ); let editors = cx.update_workspace(|workspace, _, cx| { workspace.items_of_type::(cx).collect::>() }); cx.update_editor(|_, _, _| { assert_eq!( editors.len(), 3, "should have used a new multibuffer as offsets changed" ); }); } #[gpui::test] async fn test_find_all_references_preserves_preview_tab(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings: &mut SettingsContent| { let preview_tabs = settings.preview_tabs.get_or_insert_default(); preview_tabs.enabled = Some(true); preview_tabs.enable_preview_multibuffer_from_code_navigation = Some(true); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; // Calling `set_state` here will make it such that the underlying buffer // will now report edits since its `preview_version` field, which is set // when the buffer is first created. That is one of the conditions that // determines whether a pane's item should remain as the preview item, or // not, as such, if that field is not updated accordingly, we'd end up not // having a preview item at the end of the test. cx.set_state( &r#" fn one() { let mut a = two(); } fn ˇtwo() {}"# .unindent(), ); cx.executor().run_until_parked(); cx.lsp .set_request_handler::(move |params, _| async move { Ok(Some(vec![ lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), }, lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(4, 4), lsp::Position::new(4, 7)), }, ])) }); // Using the `editor: find all references` action adds excerpts to the // multibuffer, which emits `ItemEvent::Edit`. When the pane handles that // event it decides whether to keep its preview item and, if the buffer // reports any edits since its `preview_version`, the item is unpreviewed. // We also need to run until the executor is parked because the // `ItemEvent::Edit` is delivered later, after the item has been registered // with the pane. assert_eq!( cx.update_editor(|editor, window, cx| { editor.find_all_references(&FindAllReferences::default(), window, cx) }) .expect("Editor::find_all_references should return a task") .await .expect("Should be able to find all references"), Navigated::Yes ); cx.executor().run_until_parked(); cx.update_workspace(|workspace, _window, cx| { let active_pane = workspace.active_pane().read(cx); assert_eq!( active_pane.preview_item_id(), Some(active_pane.active_item().unwrap().item_id()), "The preview item id should be the same as the id of the multibuffer" ); }); } #[gpui::test] async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" #[cfg(test)] mod tests() { #[test] fn runnable_1() { let a = 1; } #[test] fn runnable_2() { let a = 1; let b = 2; } } "# .unindent(); let fs = FakeFs::new(cx.executor()); fs.insert_file("/file.rs", Default::default()).await; let project = Project::test(fs, ["/a".as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); editor.runnables.insert( buffer.read(cx).remote_id(), 3, buffer.read(cx).version(), RunnableTasks { templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(43)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(43)..BufferOffset(85), }, ); editor.runnables.insert( buffer.read(cx).remote_id(), 8, buffer.read(cx).version(), RunnableTasks { templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(86)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(86)..BufferOffset(191), }, ); // Test finding task when cursor is inside function body editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) }); let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); assert_eq!(row, 3, "Should find task for cursor inside runnable_1"); // Test finding task when cursor is on function name editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(8, 4)..Point::new(8, 4)]) }); let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); assert_eq!(row, 8, "Should find task when cursor is on function name"); }); } #[gpui::test] async fn test_toggle_code_actions_build_tasks_context_error_notifies(cx: &mut TestAppContext) { init_test(cx, |_| {}); struct FailingContextProvider; impl ContextProvider for FailingContextProvider { fn build_context( &self, _: &TaskVariables, _: ContextLocation<'_>, _: Option>, _: Arc, _: &mut gpui::App, ) -> Task> { Task::ready(Err(anyhow::anyhow!("Task context provider failed"))) } } let language = Arc::new( Arc::try_unwrap(rust_lang()) .unwrap() .with_context_provider(Some(Arc::new(FailingContextProvider))), ); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/a"), json!({ "main.rs": "fn main() {}" })) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(language.clone()); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let mut cx = VisualTestContext::from_window(*window, cx); let workspace = window .read_with(&mut cx, |mw, _| mw.workspace().clone()) .unwrap(); let worktree_id = workspace.update_in(&mut cx, |workspace, _, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let editor = workspace .update_in(&mut cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); editor.update_in(&mut cx, |editor, window, cx| { let buffer = editor.buffer().read(cx).as_singleton().unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_language(Some(language.clone()), cx) }); let snapshot = editor.buffer().read(cx).snapshot(cx); editor.runnables.insert( buffer.read(cx).remote_id(), 0, buffer.read(cx).version(), RunnableTasks { templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(0)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(0)..BufferOffset(0), }, ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) }); editor.toggle_code_actions( &ToggleCodeActions { deployed_from: None, quick_launch: false, }, window, cx, ); }); cx.run_until_parked(); workspace.update_in(&mut cx, |workspace, _, _| { assert!(!workspace.notification_ids().is_empty()); }); } #[gpui::test] async fn test_folding_buffers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); let sample_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu".to_string(); let sample_text_3 = "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n1111\n2222\n3333\n4444\n5555".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "first.rs": sample_text_1, "second.rs": sample_text_2, "third.rs": sample_text_3, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer_1 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("first.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("third.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(10, 4), ], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(10, 4), ], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [ Point::new(0, 0)..Point::new(2, 0), Point::new(5, 0)..Point::new(6, 0), Point::new(9, 0)..Point::new(10, 4), ], 0, cx, ); multi_buffer }); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer.clone(), Some(project.clone()), window, cx, ) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\naaaa\nbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", "After folding the first buffer, its text should not be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", "After folding the second buffer, its text should not be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n\n", "After folding the third buffer, its text should not be displayed" ); // Emulate selection inside the fold logic, that should work multi_buffer_editor.update_in(cx, |editor, window, cx| { editor .snapshot(window, cx) .next_line_boundary(Point::new(0, 4)); }); multi_buffer_editor.update(cx, |editor, cx| { editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n", "After unfolding the second buffer, its text should be displayed" ); // Typing inside of buffer 1 causes that buffer to be unfolded. multi_buffer_editor.update_in(cx, |editor, window, cx| { assert_eq!( multi_buffer .read(cx) .snapshot(cx) .text_for_range(Point::new(1, 0)..Point::new(1, 4)) .collect::(), "bbbb" ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(vec![Point::new(1, 0)..Point::new(1, 0)]); }); editor.handle_input("B", window, cx); }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\naaaa\nBbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n", "After unfolding the first buffer, its and 2nd buffer's text should be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\naaaa\nBbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", "After unfolding the all buffers, all original text should be displayed" ); } #[gpui::test] async fn test_folded_buffers_cleared_on_excerpts_removed(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "file_a.txt": "File A\nFile A\nFile A", "file_b.txt": "File B\nFile B\nFile B", }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer_a = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("file_a.txt")), cx) }) .await .unwrap(); let buffer_b = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("file_b.txt")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); let range_a = Point::new(0, 0)..Point::new(2, 4); let range_b = Point::new(0, 0)..Point::new(2, 4); multi_buffer.set_excerpts_for_path(PathKey::sorted(0), buffer_a.clone(), [range_a], 0, cx); multi_buffer.set_excerpts_for_path(PathKey::sorted(1), buffer_b.clone(), [range_b], 0, cx); multi_buffer }); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer.clone(), Some(project.clone()), window, cx, ) }); editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_a.read(cx).remote_id(), cx); }); assert!(editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx))); // When the excerpts for `buffer_a` are removed, a // `multi_buffer::Event::ExcerptsRemoved` event is emitted, which should be // picked up by the editor and update the display map accordingly. multi_buffer.update(cx, |multi_buffer, cx| { multi_buffer.remove_excerpts(PathKey::sorted(0), cx) }); assert!(!editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx))); } #[gpui::test] async fn test_folding_buffers_with_one_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text_1 = "1111\n2222\n3333".to_string(); let sample_text_2 = "4444\n5555\n6666".to_string(); let sample_text_3 = "7777\n8888\n9999".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "first.rs": sample_text_1, "second.rs": sample_text_2, "third.rs": sample_text_3, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer_1 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("first.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("third.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(3, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(3, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [Point::new(0, 0)..Point::new(3, 0)], 0, cx, ); multi_buffer }); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); let full_text = "\n\n1111\n2222\n3333\n\n\n4444\n5555\n6666\n\n\n7777\n8888\n9999"; assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), full_text, ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n4444\n5555\n6666\n\n\n7777\n8888\n9999", "After folding the first buffer, its text should not be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n\n\n7777\n8888\n9999", "After folding the second buffer, its text should not be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n\n", "After folding the third buffer, its text should not be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n\n\n4444\n5555\n6666\n\n", "After unfolding the second buffer, its text should be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.unfold_buffer(buffer_1.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), "\n\n1111\n2222\n3333\n\n\n4444\n5555\n6666\n\n", "After unfolding the first buffer, its text should be displayed" ); multi_buffer_editor.update(cx, |editor, cx| { editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) }); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), full_text, "After unfolding all buffers, all original text should be displayed" ); } #[gpui::test] async fn test_folding_buffer_when_multibuffer_has_only_one_excerpt(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer_1 = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0) ..Point::new( sample_text.chars().filter(|&c| c == '\n').count() as u32 + 1, 0, )], 0, cx, ); multi_buffer }); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); let selection_range = Point::new(1, 0)..Point::new(2, 0); multi_buffer_editor.update_in(cx, |editor, window, cx| { let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); let highlight_range = selection_range.clone().to_anchors(&multi_buffer_snapshot); editor.highlight_text( HighlightKey::Editor, vec![highlight_range.clone()], HighlightStyle::color(Hsla::green()), cx, ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(Some(highlight_range)) }); }); let full_text = format!("\n\n{sample_text}"); assert_eq!( multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), full_text, ); } #[gpui::test] async fn test_multi_buffer_navigation_with_folded_buffers(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( "keymaps/default-linux.json", cx, ) .unwrap(); cx.bind_keys(default_key_bindings); }); let (editor, cx) = cx.add_window_view(|window, cx| { let multi_buffer = MultiBuffer::build_multi( [ ("a0\nb0\nc0\nd0\ne0\n", vec![Point::row_range(0..2)]), ("a1\nb1\nc1\nd1\ne1\n", vec![Point::row_range(0..2)]), ("a2\nb2\nc2\nd2\ne2\n", vec![Point::row_range(0..2)]), ("a3\nb3\nc3\nd3\ne3\n", vec![Point::row_range(0..2)]), ], cx, ); let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); let buffer_ids = multi_buffer .read(cx) .snapshot(cx) .excerpts() .map(|excerpt| excerpt.context.start.buffer_id) .collect::>(); // fold all but the second buffer, so that we test navigating between two // adjacent folded buffers, as well as folded buffers at the start and // end the multibuffer editor.fold_buffer(buffer_ids[0], cx); editor.fold_buffer(buffer_ids[2], cx); editor.fold_buffer(buffer_ids[3], cx); editor }); cx.simulate_resize(size(px(1000.), px(1000.))); let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] ˇ[FOLDED] [EXCERPT] a1 b1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("down"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] ˇa1 b1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("down"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 ˇb1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("down"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 b1 ˇ[EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("down"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 b1 [EXCERPT] ˇ[FOLDED] [EXCERPT] [FOLDED] " }); for _ in 0..5 { cx.simulate_keystroke("down"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 b1 [EXCERPT] [FOLDED] [EXCERPT] ˇ[FOLDED] " }); } cx.simulate_keystroke("up"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 b1 [EXCERPT] ˇ[FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("up"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 b1 ˇ[EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("up"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] a1 ˇb1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); cx.simulate_keystroke("up"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] ˇa1 b1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); for _ in 0..5 { cx.simulate_keystroke("up"); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] ˇ[FOLDED] [EXCERPT] a1 b1 [EXCERPT] [FOLDED] [EXCERPT] [FOLDED] " }); } } #[gpui::test] async fn test_edit_prediction_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Simple insertion assert_highlighted_edits( "Hello, world!", vec![(Point::new(0, 6)..Point::new(0, 6), " beautiful".into())], true, cx, &|highlighted_edits, cx| { assert_eq!(highlighted_edits.text, "Hello, beautiful world!"); assert_eq!(highlighted_edits.highlights.len(), 1); assert_eq!(highlighted_edits.highlights[0].0, 6..16); assert_eq!( highlighted_edits.highlights[0].1.background_color, Some(cx.theme().status().created_background) ); }, ) .await; // Replacement assert_highlighted_edits( "This is a test.", vec![(Point::new(0, 0)..Point::new(0, 4), "That".into())], false, cx, &|highlighted_edits, cx| { assert_eq!(highlighted_edits.text, "That is a test."); assert_eq!(highlighted_edits.highlights.len(), 1); assert_eq!(highlighted_edits.highlights[0].0, 0..4); assert_eq!( highlighted_edits.highlights[0].1.background_color, Some(cx.theme().status().created_background) ); }, ) .await; // Multiple edits assert_highlighted_edits( "Hello, world!", vec![ (Point::new(0, 0)..Point::new(0, 5), "Greetings".into()), (Point::new(0, 12)..Point::new(0, 12), " and universe".into()), ], false, cx, &|highlighted_edits, cx| { assert_eq!(highlighted_edits.text, "Greetings, world and universe!"); assert_eq!(highlighted_edits.highlights.len(), 2); assert_eq!(highlighted_edits.highlights[0].0, 0..9); assert_eq!(highlighted_edits.highlights[1].0, 16..29); assert_eq!( highlighted_edits.highlights[0].1.background_color, Some(cx.theme().status().created_background) ); assert_eq!( highlighted_edits.highlights[1].1.background_color, Some(cx.theme().status().created_background) ); }, ) .await; // Multiple lines with edits assert_highlighted_edits( "First line\nSecond line\nThird line\nFourth line", vec![ (Point::new(1, 7)..Point::new(1, 11), "modified".to_string()), ( Point::new(2, 0)..Point::new(2, 10), "New third line".to_string(), ), (Point::new(3, 6)..Point::new(3, 6), " updated".to_string()), ], false, cx, &|highlighted_edits, cx| { assert_eq!( highlighted_edits.text, "Second modified\nNew third line\nFourth updated line" ); assert_eq!(highlighted_edits.highlights.len(), 3); assert_eq!(highlighted_edits.highlights[0].0, 7..15); // "modified" assert_eq!(highlighted_edits.highlights[1].0, 16..30); // "New third line" assert_eq!(highlighted_edits.highlights[2].0, 37..45); // " updated" for highlight in &highlighted_edits.highlights { assert_eq!( highlight.1.background_color, Some(cx.theme().status().created_background) ); } }, ) .await; } #[gpui::test] async fn test_edit_prediction_text_with_deletions(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Deletion assert_highlighted_edits( "Hello, world!", vec![(Point::new(0, 5)..Point::new(0, 11), "".to_string())], true, cx, &|highlighted_edits, cx| { assert_eq!(highlighted_edits.text, "Hello, world!"); assert_eq!(highlighted_edits.highlights.len(), 1); assert_eq!(highlighted_edits.highlights[0].0, 5..11); assert_eq!( highlighted_edits.highlights[0].1.background_color, Some(cx.theme().status().deleted_background) ); }, ) .await; // Insertion assert_highlighted_edits( "Hello, world!", vec![(Point::new(0, 6)..Point::new(0, 6), " digital".to_string())], true, cx, &|highlighted_edits, cx| { assert_eq!(highlighted_edits.highlights.len(), 1); assert_eq!(highlighted_edits.highlights[0].0, 6..14); assert_eq!( highlighted_edits.highlights[0].1.background_color, Some(cx.theme().status().created_background) ); }, ) .await; } async fn assert_highlighted_edits( text: &str, edits: Vec<(Range, String)>, include_deletions: bool, cx: &mut TestAppContext, assertion_fn: &dyn Fn(HighlightedText, &App), ) { let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(text, cx); Editor::new(EditorMode::full(), buffer, None, window, cx) }); let cx = &mut VisualTestContext::from_window(*window, cx); let (buffer, snapshot) = window .update(cx, |editor, _window, cx| { ( editor.buffer().clone(), editor.buffer().read(cx).snapshot(cx), ) }) .unwrap(); let edits = edits .into_iter() .map(|(range, edit)| { ( snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end), edit, ) }) .collect::>(); let text_anchor_edits = edits .clone() .into_iter() .map(|(range, edit)| { ( range.start.expect_text_anchor()..range.end.expect_text_anchor(), edit.into(), ) }) .collect::>(); let edit_preview = window .update(cx, |_, _window, cx| { buffer .read(cx) .as_singleton() .unwrap() .read(cx) .preview_edits(text_anchor_edits.into(), cx) }) .unwrap() .await; cx.update(|_window, cx| { let highlighted_edits = edit_prediction_edit_text( snapshot.as_singleton().unwrap(), &edits, &edit_preview, include_deletions, &snapshot, cx, ); assertion_fn(highlighted_edits, cx) }); } #[track_caller] fn assert_breakpoint( breakpoints: &BTreeMap, Vec>, path: &Arc, expected: Vec<(u32, Breakpoint)>, ) { if expected.is_empty() { assert!(!breakpoints.contains_key(path), "{}", path.display()); } else { let mut breakpoint = breakpoints .get(path) .unwrap() .iter() .map(|breakpoint| { ( breakpoint.row, Breakpoint { message: breakpoint.message.clone(), state: breakpoint.state, condition: breakpoint.condition.clone(), hit_condition: breakpoint.hit_condition.clone(), }, ) }) .collect::>(); breakpoint.sort_by_key(|(cached_position, _)| *cached_position); assert_eq!(expected, breakpoint); } } fn add_log_breakpoint_at_cursor( editor: &mut Editor, log_message: &str, window: &mut Window, cx: &mut Context, ) { let (anchor, bp) = editor .breakpoints_at_cursors(window, cx) .first() .and_then(|(anchor, bp)| bp.as_ref().map(|bp| (*anchor, bp.clone()))) .unwrap_or_else(|| { let snapshot = editor.snapshot(window, cx); let cursor_position: Point = editor.selections.newest(&snapshot.display_snapshot).head(); let breakpoint_position = snapshot .buffer_snapshot() .anchor_before(Point::new(cursor_position.row, 0)); (breakpoint_position, Breakpoint::new_log(log_message)) }); editor.edit_breakpoint_at_anchor( anchor, bp, BreakpointEditAction::EditLogMessage(log_message.into()), cx, ); } #[gpui::test] fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut TestAppContext) { init_test(cx, |_| {}); let focus_handle = cx.update(|cx| cx.focus_handle()); let tooltip = GutterButtonTooltip { primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBookmark, focus_handle, on_render: None, }; let primary_intent = tooltip.active_intent(Modifiers::none()); assert_eq!(primary_intent, GutterButtonIntent::SetBreakpoint); assert!(primary_intent.action().as_any().is::()); let secondary_intent = tooltip.active_intent(Modifiers::secondary_key()); assert_eq!(secondary_intent, GutterButtonIntent::SetBookmark); assert!(secondary_intent.action().as_any().is::()); // When both features are enabled, the meta text advertises the // modifier-click alternative. let meta = tooltip.meta_text(); assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); assert!(meta.contains("right-click for more options")); // When only one feature is enabled (primary == secondary), a // modifier-click repeats the primary action, so the tooltip must not // advertise it. let single_feature_tooltip = GutterButtonTooltip { primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBreakpoint, focus_handle: tooltip.focus_handle, on_render: None, }; let meta = single_feature_tooltip.meta_text(); assert_eq!(meta, "right-click for more options"); } #[gpui::test] fn test_gutter_button_tooltip_renders_modifier_transitions(cx: &mut TestAppContext) { init_test(cx, |_| {}); let renders = Rc::new(RefCell::new(Vec::new())); let (tooltip, cx) = cx.add_window_view({ let renders = renders.clone(); move |_window, cx| GutterButtonTooltip { primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBookmark, focus_handle: cx.focus_handle(), on_render: Some(renders), } }); cx.run_until_parked(); let assert_render = |expected_title: &str| { let (title, meta) = renders.borrow().last().cloned().expect("tooltip rendered"); assert_eq!(title, expected_title); assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); }; assert_render("Set Breakpoint"); cx.simulate_modifiers_change(Modifiers::secondary_key()); tooltip.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_render("Set Bookmark"); cx.simulate_modifiers_change(Modifiers::none()); tooltip.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_render("Set Breakpoint"); } #[gpui::test] async fn test_breakpoint_toggling(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(buffer, cx), Some(project.clone()), window, cx, ) }); let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) .map(Arc::from) .unwrap() }); // assert we can add breakpoint on the first line editor.update_in(cx, |editor, window, cx| { editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); editor.move_to_end(&MoveToEnd, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_eq!(1, breakpoints.len()); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (3, Breakpoint::new_standard()), ], ); editor.update_in(cx, |editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_eq!(1, breakpoints.len()); assert_breakpoint( &breakpoints, &abs_path, vec![(3, Breakpoint::new_standard())], ); editor.update_in(cx, |editor, window, cx| { editor.move_to_end(&MoveToEnd, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_eq!(0, breakpoints.len()); assert_breakpoint(&breakpoints, &abs_path, vec![]); } async fn build_gutter_hover_test_editor(saved: bool, cx: &mut TestAppContext) -> EditorTestContext { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "fn main() {}\n", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let buffer = if saved { let worktree_id = project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap() } else { let buffer = project .update(cx, |project, cx| project.create_buffer(None, true, cx)) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, "fn main() {}\n")], None, cx); }); buffer }; let window = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, MultiBuffer::build_from_buffer(buffer, cx), window, cx, ); window.focus(&editor.focus_handle(cx), cx); editor }); EditorTestContext::for_editor(window, cx).await } fn hover_over_gutter_row_zero(cx: &mut EditorTestContext) { cx.update(|window, cx| { window.refresh(); let _ = window.draw(cx); }); let gutter_bounds = cx.update_editor(|editor, _, _| { editor .last_position_map .as_ref() .expect("expected editor position map") .gutter_hitbox .bounds }); let hover_position = gutter_bounds.center(); cx.simulate_mouse_move(hover_position, None, Modifiers::none()); } #[gpui::test] async fn test_gutter_hover_button_shown_for_saved_buffer(cx: &mut TestAppContext) { let mut cx = build_gutter_hover_test_editor(true, cx).await; hover_over_gutter_row_zero(&mut cx); cx.update_editor(|editor, _, _| { assert!( editor.gutter_hover_button.0.is_some(), "expected gutter hover button to be armed for a saved buffer" ); assert!(!editor.gutter_hover_button.0.as_ref().unwrap().is_active); }); cx.executor().advance_clock(Duration::from_millis(250)); cx.run_until_parked(); cx.update_editor(|editor, _, _| { assert!( editor .gutter_hover_button .0 .as_ref() .is_some_and(|indicator| indicator.is_active), "expected gutter hover button to become active after the debounce" ); }); } #[gpui::test] async fn test_gutter_hover_button_hidden_for_unsaved_buffer(cx: &mut TestAppContext) { let mut cx = build_gutter_hover_test_editor(false, cx).await; hover_over_gutter_row_zero(&mut cx); cx.update_editor(|editor, _, _| { assert!( editor.gutter_hover_button.0.is_none(), "expected no gutter hover button for an unsaved buffer" ); assert!( editor.gutter_hover_button.1.is_none(), "expected no debounce task to be armed for an unsaved buffer" ); }); cx.executor().advance_clock(Duration::from_millis(250)); cx.run_until_parked(); cx.update_editor(|editor, _, _| { assert!(editor.gutter_hover_button.0.is_none()); }); } fn right_click_gutter_row_zero(cx: &mut EditorTestContext) { cx.update(|window, cx| { window.refresh(); let _ = window.draw(cx); }); let gutter_bounds = cx.update_editor(|editor, _, _| { editor .last_position_map .as_ref() .expect("expected editor position map") .gutter_hitbox .bounds }); let click_position = gutter_bounds.center(); cx.simulate_mouse_down(click_position, MouseButton::Right, Modifiers::none()); } #[gpui::test] async fn test_gutter_context_menu_shown_for_saved_buffer(cx: &mut TestAppContext) { let mut cx = build_gutter_hover_test_editor(true, cx).await; right_click_gutter_row_zero(&mut cx); cx.update_editor(|editor, _, _| { assert!( editor.mouse_context_menu.is_some(), "expected gutter context menu to open for a saved buffer" ); }); } #[gpui::test] async fn test_gutter_context_menu_hidden_for_unsaved_buffer(cx: &mut TestAppContext) { let mut cx = build_gutter_hover_test_editor(false, cx).await; right_click_gutter_row_zero(&mut cx); cx.update_editor(|editor, _, _| { assert!( editor.mouse_context_menu.is_none(), "expected no gutter context menu for an unsaved buffer" ); }); } #[gpui::test] async fn test_gutter_context_menu_anchor_in_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let root_path = path!("/root"); fs.insert_tree( root_path, json!({ "first.rs": "fn one() {}\nfn two() {}\nfn three() {}\n", "second.rs": "fn four() {}\nfn five() {}\nfn six() {}\n", }), ) .await; let project = Project::test(fs, [root_path.as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); let buffer_a = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("first.rs")), cx) }) .await .unwrap(); let buffer_b = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::sorted(0), buffer_a.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multi_buffer.set_excerpts_for_path( PathKey::sorted(1), buffer_b.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multi_buffer }); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); // Open the gutter context menu on a display row inside the second excerpt, // ensuring that excerpt headers will now play a part in offsetting display // rows from buffer rows. editor.update_in(cx, |editor, window, cx| { let display_snapshot = editor.display_snapshot(cx); let buffer_snapshot = display_snapshot.buffer_snapshot(); let buffer_anchor = buffer_b.read(cx).snapshot().anchor_before(Point::new(1, 0)); let multibuffer_anchor = buffer_snapshot.anchor_in_buffer(buffer_anchor).unwrap(); let buffer_point = buffer_snapshot.summary_for_anchor::(&multibuffer_anchor); let display_row = display_snapshot .point_to_display_point(buffer_point, Bias::Left) .row(); assert_ne!( display_row.0, buffer_point.row, "display row should be offset from the multibuffer row by excerpt headers" ); editor.set_gutter_context_menu(display_row, None, Default::default(), window, cx); }); // We'll now confirm that the `MenuPosition`'s source has been set to the // correct buffer anchor (1, 0), instead of simply taking the display row. editor.update(cx, |editor, cx| { let menu = editor.mouse_context_menu.as_ref().unwrap(); let snapshot = editor.buffer().read(cx).snapshot(cx); let MenuPosition::PinnedToEditor { source, .. } = &menu.position else { panic!("expected gutter context menu to be pinned to the editor"); }; let (buffer_anchor, buffer_snapshot) = snapshot .anchor_to_buffer_anchor(*source) .expect("menu anchor should resolve to a buffer"); assert_eq!(buffer_snapshot.remote_id(), buffer_b.read(cx).remote_id()); assert_eq!( buffer_snapshot.summary_for_anchor::(&buffer_anchor), Point::new(1, 0), ); }); } #[gpui::test] async fn test_breakpoint_after_save_as_existing_path(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "First line\nSecond line\nThird line\nFourth line", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let first_buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (first_editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(first_buffer, cx), Some(project.clone()), window, cx, ) }); first_editor.update_in(cx, |editor, window, cx| { editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let replacement_buffer = project.update(cx, |project, cx| { project.create_local_buffer("Alpha\nBeta\nGamma", None, true, cx) }); project .update(cx, |project, cx| { project.save_buffer_as( replacement_buffer.clone(), ProjectPath { worktree_id, path: rel_path("main.rs").into(), }, cx, ) }) .await .unwrap(); let (replacement_editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(replacement_buffer, cx), Some(project.clone()), window, cx, ) }); replacement_editor.update_in(cx, |editor, window, cx| { editor.move_down(&MoveDown, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let project_path = first_editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) .map(Arc::from) .unwrap() }); let breakpoints = first_editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .source_breakpoints_from_path(&abs_path, cx) }); assert_eq!( vec![0, 1], breakpoints .into_iter() .map(|breakpoint| breakpoint.row) .collect::>() ); } #[gpui::test] async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(buffer, cx), Some(project.clone()), window, cx, ) }); let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) .map(Arc::from) .unwrap() }); editor.update_in(cx, |editor, window, cx| { add_log_breakpoint_at_cursor(editor, "hello world", window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_breakpoint( &breakpoints, &abs_path, vec![(0, Breakpoint::new_log("hello world"))], ); // Removing a log message from a log breakpoint should remove it editor.update_in(cx, |editor, window, cx| { add_log_breakpoint_at_cursor(editor, "", window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_breakpoint(&breakpoints, &abs_path, vec![]); editor.update_in(cx, |editor, window, cx| { editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); editor.move_to_end(&MoveToEnd, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); // Not adding a log message to a standard breakpoint shouldn't remove it add_log_breakpoint_at_cursor(editor, "", window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (3, Breakpoint::new_standard()), ], ); editor.update_in(cx, |editor, window, cx| { add_log_breakpoint_at_cursor(editor, "hello world", window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (3, Breakpoint::new_log("hello world")), ], ); editor.update_in(cx, |editor, window, cx| { add_log_breakpoint_at_cursor(editor, "hello Earth!!", window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (3, Breakpoint::new_log("hello Earth!!")), ], ); } /// This also tests that Editor::breakpoint_at_cursor_head is working properly /// we had some issues where we wouldn't find a breakpoint at Point {row: 0, col: 0} /// or when breakpoints were placed out of order. This tests for a regression too #[gpui::test] async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { init_test(cx, |_| {}); let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(buffer, cx), Some(project.clone()), window, cx, ) }); let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) .map(Arc::from) .unwrap() }); // assert we can add breakpoint on the first line editor.update_in(cx, |editor, window, cx| { editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); editor.move_to_end(&MoveToEnd, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); editor.move_up(&MoveUp, window, cx); editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_eq!(1, breakpoints.len()); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (2, Breakpoint::new_standard()), (3, Breakpoint::new_standard()), ], ); editor.update_in(cx, |editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); editor.move_to_end(&MoveToEnd, window, cx); editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); // Disabling a breakpoint that doesn't exist should do nothing editor.move_up(&MoveUp, window, cx); editor.move_up(&MoveUp, window, cx); editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); let disable_breakpoint = { let mut bp = Breakpoint::new_standard(); bp.state = BreakpointState::Disabled; bp }; assert_eq!(1, breakpoints.len()); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, disable_breakpoint.clone()), (2, Breakpoint::new_standard()), (3, disable_breakpoint.clone()), ], ); editor.update_in(cx, |editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.enable_breakpoint(&actions::EnableBreakpoint, window, cx); editor.move_to_end(&MoveToEnd, window, cx); editor.enable_breakpoint(&actions::EnableBreakpoint, window, cx); editor.move_up(&MoveUp, window, cx); editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); }); let breakpoints = editor.update(cx, |editor, cx| { editor .breakpoint_store() .as_ref() .unwrap() .read(cx) .all_source_breakpoints(cx) }); assert_eq!(1, breakpoints.len()); assert_breakpoint( &breakpoints, &abs_path, vec![ (0, Breakpoint::new_standard()), (2, disable_breakpoint), (3, Breakpoint::new_standard()), ], ); } struct BookmarkTestContext { project: Entity, editor: Entity, cx: VisualTestContext, } impl BookmarkTestContext { async fn new(sample_text: &str, cx: &mut TestAppContext) -> BookmarkTestContext { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": sample_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let mut visual_cx = VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(&mut visual_cx, |workspace, _window, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let buffer = project .update(&mut visual_cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (editor, editor_cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), MultiBuffer::build_from_buffer(buffer, cx), Some(project.clone()), window, cx, ) }); let cx = editor_cx.clone(); BookmarkTestContext { project, editor, cx, } } fn abs_path(&self) -> Arc { let project_path = self.editor.read_with(&self.cx, |editor, cx| { editor.active_project_path(cx).unwrap() }); self.project.read_with(&self.cx, |project, cx| { project .absolute_path(&project_path, cx) .map(Arc::from) .unwrap() }) } fn all_bookmarks(&self) -> BTreeMap, Vec> { self.project.read_with(&self.cx, |project, cx| { project .bookmark_store() .read(cx) .all_serialized_bookmarks(cx) }) } fn assert_bookmarked_file_count(&self, expected_count: usize) { assert_eq!(expected_count, self.all_bookmarks().len()); } fn assert_bookmark_rows(&self, expected_rows: Vec) { let abs_path = self.abs_path(); let bookmarks = self.all_bookmarks(); if expected_rows.is_empty() { assert!( !bookmarks.contains_key(&abs_path), "Expected no bookmarks for {}", abs_path.display() ); } else { let mut rows: Vec = bookmarks .get(&abs_path) .unwrap() .iter() .map(|b| b.row) .collect(); rows.sort(); assert_eq!(expected_rows, rows); } } fn assert_bookmark_labels(&self, expected_labels: Vec<(u32, &str)>) { let abs_path = self.abs_path(); let bookmarks = self.all_bookmarks(); let mut labels: Vec<(u32, &str)> = bookmarks .get(&abs_path) .unwrap() .iter() .map(|bookmark| (bookmark.row, bookmark.label.as_str())) .collect(); labels.sort_by_key(|(row, _)| *row); assert_eq!(expected_labels, labels); } fn confirm_action_available(&mut self) -> bool { self.cx .update(|window, cx| window.is_action_available(&menu::Confirm, cx)) } fn select_rows(&mut self, rows: &[u32]) { assert!(!rows.is_empty(), "expected at least one row to select"); self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges( rows.iter() .copied() .map(|row| Point::new(row, 0)..Point::new(row, 0)), ) }); }); } fn prompt_blocks(&mut self) -> Vec<(DisplayRow, u32)> { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); let max_row = snapshot.max_point().row().next_row(); snapshot .blocks_in_range(DisplayRow(0)..max_row) .filter_map(|(row, block)| match block { crate::display_map::Block::Custom(_) => Some((row, block.height())), _ => None, }) .collect() }) } fn assert_prompt_block_count(&mut self, expected_count: usize) { assert_eq!(expected_count, self.prompt_blocks().len()); } fn draw_window(&mut self) { self.cx.update(|window, cx| { window.refresh(); let _ = window.draw(cx); }); } fn focus_bookmark_prompt_block(&mut self, block_index: usize) { self.draw_window(); let prompt_blocks = self.prompt_blocks(); let (block_row, block_height) = *prompt_blocks .get(block_index) .expect("expected bookmark prompt block"); let click_position = self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { let snapshot = editor.snapshot(window, cx); let block_top = DisplayPoint::new(block_row, 0); let relative_block_top = editor .display_to_pixel_point(block_top, &snapshot, window, cx) .expect("expected prompt block to be visible"); let line_height = editor .style(cx) .text .line_height_in_pixels(window.rem_size()); let editor_origin = editor .last_position_map .as_ref() .expect("expected editor position map") .text_hitbox .bounds .origin; let editor_center_x = editor .last_bounds .expect("expected editor bounds") .center() .x; gpui::Point { x: editor_center_x, y: editor_origin.y + relative_block_top.y + line_height * (block_height as f32 / 2.), } }); self.cx .simulate_click(click_position, gpui::Modifiers::none()); self.cx.run_until_parked(); assert!( self.confirm_action_available(), "expected bookmark prompt block to be focused" ); } fn confirm_bookmark_prompt_at_block_index(&mut self, block_index: usize, label: &str) { // Confirming a PromptEditor returns focus to the parent editor, so each remaining // prompt block must be focused explicitly before typing into it. self.focus_bookmark_prompt_block(block_index); self.confirm_bookmark_prompt(label); } fn cursor_row(&mut self) -> u32 { self.cursor_point().row } fn cursor_point(&mut self) -> Point { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); editor.selections.newest::(&snapshot).head() }) } fn move_to_row(&mut self, row: u32) { self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); for _ in 0..row { editor.move_down(&MoveDown, window, cx); } }); } fn toggle_bookmark(&mut self) { self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); } fn toggle_bookmark_with_label(&mut self) { self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.toggle_bookmark_with_label(&actions::ToggleBookmarkWithLabel, window, cx); }); } fn confirm_bookmark_prompt(&mut self, label: &str) { if !label.is_empty() { self.cx.simulate_input(label); } self.cx.dispatch_action(menu::Confirm); self.cx.run_until_parked(); } fn add_bookmark_with_label(&mut self, label: &str) { self.toggle_bookmark_with_label(); self.confirm_bookmark_prompt(label); } fn toggle_bookmarks_at_rows(&mut self, rows: &[u32]) { for &row in rows { self.move_to_row(row); self.add_bookmark_with_label(""); } } fn go_to_next_bookmark(&mut self) { self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.go_to_next_bookmark(&actions::GoToNextBookmark, window, cx); }); } fn go_to_previous_bookmark(&mut self) { self.editor .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { editor.go_to_previous_bookmark(&actions::GoToPreviousBookmark, window, cx); }); } } #[gpui::test] async fn test_bookmark_toggling(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.add_bookmark_with_label(""); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_end(&MoveToEnd, window, cx); }); ctx.add_bookmark_with_label(""); ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![0, 3]); ctx.move_to_row(0); ctx.toggle_bookmark(); ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![3]); ctx.move_to_row(3); ctx.toggle_bookmark(); ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_bookmark_toggling_unnamed_with_multiple_selections(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.select_rows(&[0, 1, 2]); ctx.toggle_bookmark(); ctx.assert_prompt_block_count(0); ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_labels(vec![(0, ""), (1, ""), (2, "")]); ctx.select_rows(&[0, 1, 2, 3]); ctx.toggle_bookmark(); ctx.assert_prompt_block_count(0); ctx.assert_bookmark_rows(vec![0, 1, 2, 3]); ctx.select_rows(&[0, 1, 2, 3]); ctx.toggle_bookmark(); ctx.assert_prompt_block_count(0); ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_bookmark_toggling_with_label_with_multiple_selections(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.select_rows(&[0, 1, 2]); ctx.toggle_bookmark_with_label(); ctx.assert_prompt_block_count(3); ctx.assert_bookmarked_file_count(0); ctx.confirm_bookmark_prompt_at_block_index(0, "first label"); ctx.assert_prompt_block_count(2); ctx.confirm_bookmark_prompt_at_block_index(0, "second label"); ctx.assert_prompt_block_count(1); ctx.confirm_bookmark_prompt_at_block_index(0, "third label"); ctx.assert_prompt_block_count(0); ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_labels(vec![ (0, "first label"), (1, "second label"), (2, "third label"), ]); ctx.select_rows(&[0, 1, 2, 3]); ctx.toggle_bookmark_with_label(); ctx.assert_prompt_block_count(1); ctx.assert_bookmark_labels(vec![ (0, "first label"), (1, "second label"), (2, "third label"), ]); ctx.confirm_bookmark_prompt_at_block_index(0, "fourth label"); ctx.assert_prompt_block_count(0); ctx.assert_bookmark_labels(vec![ (0, "first label"), (1, "second label"), (2, "third label"), (3, "fourth label"), ]); ctx.select_rows(&[0, 1, 2, 3]); ctx.toggle_bookmark_with_label(); ctx.assert_prompt_block_count(0); ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); }); ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![0]); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_end_of_line( &MoveToEndOfLine { stop_at_soft_wraps: true, }, window, cx, ); }); ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_bookmark_survives_edits(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.move_to_row(2); ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![2]); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.newline(&Newline, window, cx); }); ctx.assert_bookmark_rows(vec![3]); ctx.move_to_row(3); ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_active_bookmarks(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new( "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", cx, ) .await; ctx.toggle_bookmarks_at_rows(&[1, 3, 5, 8]); let active = ctx .editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.active_bookmarks(DisplayRow(0)..DisplayRow(10), window, cx) }); assert!(active.contains(&DisplayRow(1))); assert!(active.contains(&DisplayRow(3))); assert!(active.contains(&DisplayRow(5))); assert!(active.contains(&DisplayRow(8))); assert!(!active.contains(&DisplayRow(0))); assert!(!active.contains(&DisplayRow(2))); assert!(!active.contains(&DisplayRow(9))); let active = ctx .editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.active_bookmarks(DisplayRow(2)..DisplayRow(6), window, cx) }); assert!(active.contains(&DisplayRow(3))); assert!(active.contains(&DisplayRow(5))); assert!(!active.contains(&DisplayRow(1))); assert!(!active.contains(&DisplayRow(8))); } #[gpui::test] async fn test_clearing_bookmark_store_notifies_editor(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("Line 0\nLine 1\nLine 2\nLine 3", cx).await; ctx.toggle_bookmarks_at_rows(&[1, 3]); let active = ctx .editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) }); assert!(active.contains(&DisplayRow(1))); assert!(active.contains(&DisplayRow(3))); let editor = ctx.editor.clone(); let editor_notified = Rc::new(RefCell::new(false)); let _subscription = ctx.cx.update(|_, cx| { cx.observe(&editor, { let editor_notified = editor_notified.clone(); move |_, _| *editor_notified.borrow_mut() = true }) }); // `workspace::ClearBookmarks` mutates only the store, so the editor's gutter // repaint relies entirely on its observation of the bookmark store. ctx.project.update(&mut ctx.cx, |project, cx| { project.bookmark_store().update(cx, |bookmark_store, cx| { bookmark_store.clear_bookmarks(cx); }); }); ctx.cx.run_until_parked(); assert!( *editor_notified.borrow(), "clearing the bookmark store should notify the editor so its gutter repaints" ); let active = ctx .editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) }); assert!(active.is_empty()); } #[gpui::test] async fn test_clear_bookmarks_action_repaints_workspace_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "Line 0\nLine 1\nLine 2\nLine 3" }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let mut cx = VisualTestContext::from_window(*window, cx); let workspace = window .read_with(&mut cx, |mw, _| mw.workspace().clone()) .unwrap(); let worktree_id = workspace.update_in(&mut cx, |workspace, _, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let editor = workspace .update_in(&mut cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); cx.run_until_parked(); editor.update_in(&mut cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) }); editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); let active = editor.update_in(&mut cx, |editor, window, cx| { editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) }); assert!(active.contains(&DisplayRow(1))); // Drain the effects still pending from the toggle above, so that the only // thing that can notify the editor below is the action dispatch. cx.run_until_parked(); let editor_notified = Rc::new(RefCell::new(false)); let _subscription = cx.update(|_, cx| { cx.observe(&editor, { let editor_notified = editor_notified.clone(); move |_, _| *editor_notified.borrow_mut() = true }) }); cx.dispatch_action(workspace::ClearBookmarks); cx.run_until_parked(); assert!( *editor_notified.borrow(), "workspace::ClearBookmarks should notify the editor so its gutter repaints" ); let active = editor.update_in(&mut cx, |editor, window, cx| { editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) }); assert!(active.is_empty()); } #[gpui::test] async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (editor, _cx) = cx.add_window_view(|window, cx| Editor::single_line(window, cx)); editor.update(cx, |editor, _cx| { assert!( editor.bookmark_store.is_none(), "Single-line editors should not have a bookmark store" ); }); } #[gpui::test] async fn test_edit_bookmark_does_not_open_prompt_without_existing_bookmark( cx: &mut TestAppContext, ) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; assert!(!ctx.confirm_action_available()); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.edit_bookmark(&actions::EditBookmark, window, cx); }); assert!(!ctx.confirm_action_available()); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_edit_bookmark_updates_label_after_confirmation(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.add_bookmark_with_label("old label"); ctx.assert_bookmark_labels(vec![(0, "old label")]); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.edit_bookmark(&actions::EditBookmark, window, cx); }); assert!(ctx.confirm_action_available()); ctx.cx.dispatch_action(SelectAll); ctx.cx.simulate_input("new label"); ctx.cx.dispatch_action(menu::Confirm); ctx.assert_bookmark_labels(vec![(0, "new label")]); } #[gpui::test] async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.move_down(&MoveDown, window, cx); editor.move_to_end_of_line( &MoveToEndOfLine { stop_at_soft_wraps: true, }, window, cx, ); }); let column_before_toggle = ctx.cursor_point().column; assert_eq!( column_before_toggle, 11, "Cursor should be at the 11th column before toggling bookmark, got column {column_before_toggle}" ); ctx.add_bookmark_with_label(""); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); }); ctx.go_to_next_bookmark(); let cursor = ctx.cursor_point(); assert_eq!(cursor.row, 1, "Should navigate to the bookmarked row"); assert_eq!( cursor.column, 0, "Bookmark navigation should always land at column 0" ); } #[gpui::test] async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( cx: &mut TestAppContext, ) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); editor.move_down(&MoveDown, window, cx); editor.move_to_end_of_line( &MoveToEndOfLine { stop_at_soft_wraps: true, }, window, cx, ); }); ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![1]); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning_of_line( &MoveToBeginningOfLine { stop_at_soft_wraps: true, stop_at_indent: false, }, window, cx, ); }); ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } #[gpui::test] async fn test_go_to_next_bookmark(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new( "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", cx, ) .await; ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); ctx.move_to_row(0); ctx.go_to_next_bookmark(); assert_eq!( ctx.cursor_row(), 2, "First next-bookmark should go to row 2" ); ctx.go_to_next_bookmark(); assert_eq!( ctx.cursor_row(), 5, "Second next-bookmark should go to row 5" ); ctx.go_to_next_bookmark(); assert_eq!( ctx.cursor_row(), 8, "Third next-bookmark should go to row 8" ); ctx.go_to_next_bookmark(); assert_eq!( ctx.cursor_row(), 2, "Next-bookmark should wrap around to row 2" ); } #[gpui::test] async fn test_go_to_previous_bookmark(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new( "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", cx, ) .await; ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_end(&MoveToEnd, window, cx); }); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 8, "First prev-bookmark should go to row 8" ); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 5, "Second prev-bookmark should go to row 5" ); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 2, "Third prev-bookmark should go to row 2" ); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 8, "Prev-bookmark should wrap around to row 8" ); } #[gpui::test] async fn test_go_to_bookmark_when_cursor_on_bookmarked_line(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new( "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", cx, ) .await; ctx.toggle_bookmarks_at_rows(&[3, 7]); ctx.move_to_row(3); ctx.go_to_next_bookmark(); assert_eq!( ctx.cursor_row(), 7, "Next from bookmarked row 3 should go to row 7" ); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 3, "Previous from bookmarked row 7 should go to row 3" ); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 7, "Next from row 3 should go to row 7"); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 3, "Next from row 7 should wrap to row 3"); } #[gpui::test] async fn test_go_to_bookmark_with_out_of_order_bookmarks(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new( "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", cx, ) .await; ctx.toggle_bookmarks_at_rows(&[8, 1, 5]); ctx.move_to_row(0); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 1, "First next should go to row 1"); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 5, "Second next should go to row 5"); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 8, "Third next should go to row 8"); ctx.go_to_next_bookmark(); assert_eq!(ctx.cursor_row(), 1, "Fourth next should wrap to row 1"); ctx.go_to_previous_bookmark(); assert_eq!( ctx.cursor_row(), 8, "Prev from row 1 should wrap around to row 8" ); ctx.go_to_previous_bookmark(); assert_eq!(ctx.cursor_row(), 5, "Prev from row 8 should go to row 5"); ctx.go_to_previous_bookmark(); assert_eq!(ctx.cursor_row(), 1, "Prev from row 5 should go to row 1"); } #[gpui::test] async fn test_empty_rename_is_no_op(cx: &mut TestAppContext) { init_test(cx, |_| {}); let capabilities = lsp::ServerCapabilities { rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { prepare_provider: Some(true), work_done_progress_options: Default::default(), })), ..Default::default() }; let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; cx.set_state("let aˇbc = 1;"); let rename_request_count = Arc::new(AtomicUsize::new(0)); let _rename_handler = cx.set_request_handler::({ let rename_request_count = rename_request_count.clone(); move |_, _, _| { rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); async { Ok(None) } } }); let mut prepare_rename_handler = cx .set_request_handler::( move |_, _, _| async move { Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { start: lsp::Position { line: 0, character: 4, }, end: lsp::Position { line: 0, character: 7, }, }))) }, ); for new_name in ["", " "] { let prepare_rename_task = cx .update_editor(|editor, window, cx| editor.rename(&Rename, window, cx)) .expect("Prepare rename was not started"); prepare_rename_handler.next().await.unwrap(); prepare_rename_task.await.expect("Prepare rename failed"); let rename_editor = cx.editor(|editor, _, _| { editor .pending_rename() .expect("Rename should still be pending") .editor .clone() }); rename_editor.update_in(&mut cx.cx.cx, |editor, window, cx| { editor.backspace(&Backspace, window, cx); editor.insert(new_name, window, cx); }); assert_eq!( cx.editor(|editor, _, cx| { editor .pending_rename() .expect("Rename should still be pending") .editor .read(cx) .text(cx) }), new_name ); cx.update_editor(|editor, window, cx| { editor .confirm_rename(&ConfirmRename, window, cx) .expect("Confirm rename should consume the action") }) .await .expect("Confirm rename failed"); assert_eq!(rename_request_count.load(atomic::Ordering::SeqCst), 0); assert!(cx.editor(|editor, _, _| editor.pending_rename().is_none())); assert_eq!(cx.buffer_text(), "let abc = 1;"); } } #[gpui::test] async fn test_bookmarks_tab_auto_refresh(cx: &mut TestAppContext) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", "other.rs": "other0\nother1\nother2\nother3\n", }), ) .await; toggle_bookmark_on_row(&editor, 0, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 1, "bookmarks editor should have 1 excerpt after first bookmark" ); toggle_bookmark_on_row(&editor, 7, &mut cx); cx.run_until_parked(); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 2, "bookmarks editor should auto-refresh to 2 excerpts after second bookmark" ); toggle_bookmark_on_row(&editor, 7, &mut cx); cx.run_until_parked(); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 1, "bookmarks editor should auto-refresh back to 1 excerpt after removing a bookmark" ); assert_eq!( cursor_line_text(&bookmarks_editor, &mut cx), "line0", "refreshing must keep the surviving excerpt intact, preserving selections in it" ); let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; toggle_bookmark_on_row(&other_editor, 1, &mut cx); cx.run_until_parked(); assert_eq!( bookmarked_paths(&bookmarks_editor, &mut cx), vec!["main.rs", "other.rs"], "bookmarks editor should hold one path per bookmarked file" ); toggle_bookmark_on_row(&editor, 0, &mut cx); cx.run_until_parked(); assert_eq!( bookmarked_paths(&bookmarks_editor, &mut cx), vec!["other.rs"], "unbookmarking a file's last line should drop its path from the bookmarks editor" ); } #[gpui::test] async fn test_bookmarks_tab_selects_first_bookmark_despite_concurrent_change( cx: &mut TestAppContext, ) { let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", }), ) .await; toggle_bookmark_on_row(&editor, 5, &mut cx); cx.run_until_parked(); open_bookmarks_tab(&workspace, &mut cx); toggle_bookmark_on_row(&editor, 9, &mut cx); cx.run_until_parked(); let bookmarks_editor = find_bookmarks_editor(&workspace, &mut cx) .expect("bookmarks editor should exist after view_bookmarks"); assert_eq!( cursor_line_text(&bookmarks_editor, &mut cx), "line5", "a bookmark change racing the tab creation must not lose the initial selection" ); } #[gpui::test] async fn test_view_bookmarks_without_resolvable_bookmarks_opens_no_tab(cx: &mut TestAppContext) { let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", "other.rs": "other0\n", "third.rs": "third0\n", }), ) .await; let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); load_test_bookmarks( &bookmark_store, vec![(bookmark_path(path!("/a/third.rs")), vec![99])], &mut cx, ) .await; let worktree_id = project.update(&mut cx, |project, cx| { project .worktrees(cx) .next() .expect("project should have at least one worktree") .read(cx) .id() }); let other_buffer = project .update(&mut cx, |project, cx| { project.open_buffer((worktree_id, rel_path("other.rs")), cx) }) .await .expect("other.rs buffer should open"); let probe_anchor = other_buffer.update(&mut cx, |buffer, _| { buffer.text_snapshot().anchor_before(Point::new(0, 0)) }); bookmark_store.update(&mut cx, |store, cx| { assert_eq!( store .find_bookmark(&other_buffer, probe_anchor, cx) .map(|bookmark| bookmark.label.clone()), None, "a probe on a bookmark-less file should find nothing" ); }); open_bookmarks_tab(&workspace, &mut cx); cx.run_until_parked(); assert_eq!( find_bookmarks_editor(&workspace, &mut cx), None, "a store whose bookmarks all fail to resolve must not open an empty tab, even after a find_bookmark probe" ); } #[gpui::test] async fn test_bookmarks_tab_survives_split(cx: &mut TestAppContext) { let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", }), ) .await; toggle_bookmark_on_row(&editor, 0, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| { cx.new(|cx| editor.clone(window, cx)) }); assert!( split_editor.read_with(&cx, |editor, _| editor.bookmarks_tab_state.is_some()), "split clone should share the bookmarks tab state" ); assert!( split_editor.read_with(&cx, |editor, cx| editor .highlighted_rows::(cx) .next() .is_some()), "split clone should carry over the bookmark line highlights" ); toggle_bookmark_on_row(&editor, 7, &mut cx); close_bookmarks_editor(&pane, bookmarks_editor, &mut cx); assert_eq!( excerpt_count(&split_editor, &mut cx), 2, "split should refresh itself when the original tab closes with a refresh in flight" ); toggle_bookmark_on_row(&editor, 7, &mut cx); cx.run_until_parked(); assert_eq!( excerpt_count(&split_editor, &mut cx), 1, "split clone's subscription should keep refreshing after the original tab closes" ); } #[gpui::test] async fn test_bookmark_resolution_drop_emits_store_event(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "line0\nline1\n", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let worktree_id = project.read_with(cx, |project, cx| { project .worktrees(cx) .next() .expect("project should have at least one worktree") .read(cx) .id() }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .expect("main.rs buffer should open"); let bookmark_store = project.read_with(cx, |project, _| project.bookmark_store()); let abs_path = cx .update(|cx| BookmarkStore::abs_path_from_buffer(&buffer, cx)) .expect("buffer should have an absolute path"); bookmark_store .update(cx, |store, cx| { store.load_serialized_bookmarks( BTreeMap::from([(abs_path, serialized_rows(&[0, 99]))]), cx, ) }) .await .expect("loading serialized bookmarks should succeed"); let events = Rc::new(RefCell::new(Vec::::new())); let _subscription = cx.update({ let events = events.clone(); |cx| { cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| { events.borrow_mut().push(event.clone()); }) } }); let (buffer_id, snapshot) = buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.text_snapshot())); let resolved_count = bookmark_store.update(cx, |store, cx| { store .bookmarks_for_buffer( buffer.clone(), text::Anchor::min_max_range_for_buffer(buffer_id), &snapshot, cx, ) .len() }); assert_eq!( resolved_count, 1, "only the in-range bookmark should survive resolution" ); assert_eq!( *events.borrow(), vec![BookmarkStoreEvent::BookmarksChanged], "dropping the out-of-range bookmark during resolution should emit BookmarksChanged" ); let resolved_count = bookmark_store.update(cx, |store, cx| { store .bookmarks_for_buffer( buffer.clone(), text::Anchor::min_max_range_for_buffer(buffer_id), &snapshot, cx, ) .len() }); assert_eq!( resolved_count, 1, "an already-resolved entry should keep its bookmark" ); assert_eq!( *events.borrow(), vec![BookmarkStoreEvent::BookmarksChanged], "resolution of an already-loaded entry should not emit again" ); bookmark_store.update(cx, |store, cx| { store.edit_bookmark( &buffer, snapshot.anchor_before(Point::new(0, 0)), "renamed".to_string(), cx, ) }); assert_eq!( *events.borrow(), vec![ BookmarkStoreEvent::BookmarksChanged, BookmarkStoreEvent::LabelChanged ], "editing a bookmark's label should emit LabelChanged rather than a set change" ); } #[gpui::test] async fn test_bookmarks_tab_highlights_bookmark_on_trailing_empty_line(cx: &mut TestAppContext) { let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", }), ) .await; toggle_bookmark_on_row(&editor, 2, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( highlighted_display_rows_of(&bookmarks_editor, &mut cx), vec![last_display_row(&bookmarks_editor, &mut cx)], "a bookmark on the trailing empty line should still highlight its row" ); } #[gpui::test] async fn test_expand_excerpts_in_bookmarks_tab_with_trailing_empty_excerpt( cx: &mut TestAppContext, ) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test(cx, json!({ "main.rs": "aaa", "other.rs": "zero\none\n" })).await; cx.update(|_, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.editor.excerpt_context_lines = Some(0); }); }); }); toggle_bookmark_on_row(&editor, 0, &mut cx); let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; toggle_bookmark_on_row(&other_editor, 2, &mut cx); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), "aaa\n" ); cx.dispatch_action(SelectAll); cx.dispatch_action(ExpandExcerptsUp { lines: 1 }); cx.run_until_parked(); assert_eq!( bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), "aaa\none\n" ); } #[gpui::test] async fn test_bookmarks_tab_keeps_unopenable_paths(cx: &mut TestAppContext) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", "unopenable.rs": {}, }), ) .await; let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); let unopenable_path = bookmark_path(path!("/a/unopenable.rs")); load_test_bookmarks( &bookmark_store, vec![ (bookmark_path(path!("/a/main.rs")), vec![0]), (Arc::clone(&unopenable_path), vec![0]), ], &mut cx, ) .await; let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 1, "only the openable file should produce an excerpt" ); assert_eq!( failed_paths(&bookmark_store, &mut cx), vec![Arc::clone(&unopenable_path)], "the unopenable path should be recorded to avoid retrying it on every refresh" ); toggle_bookmark_on_row(&editor, 7, &mut cx); cx.run_until_parked(); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 2, "refreshes should keep working while an unopenable path is recorded" ); let serialized = bookmark_store.read_with(&cx, |store, cx| store.all_serialized_bookmarks(cx)); assert_eq!( serialized.get(&unopenable_path).map(|rows| rows.len()), Some(1), "an unopenable path must keep its serialized bookmark instead of losing it" ); } #[gpui::test] async fn test_view_bookmarks_activates_most_recent_tab(cx: &mut TestAppContext) { let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", }), ) .await; toggle_bookmark_on_row(&editor, 0, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| { cx.new(|cx| editor.clone(window, cx)) }); let second_pane = workspace.update_in(&mut cx, |workspace, window, cx| { workspace.split_pane(pane.clone(), SplitDirection::Right, window, cx) }); second_pane.update_in(&mut cx, |pane, window, cx| { pane.add_item(Box::new(split_editor.clone()), true, true, None, window, cx); }); cx.run_until_parked(); open_bookmarks_tab(&workspace, &mut cx); cx.run_until_parked(); let active_editor = workspace.update(&mut cx, |workspace, cx| { workspace.active_item_as::(cx) }); assert_eq!( active_editor, Some(split_editor), "view_bookmarks should activate the most recently used bookmarks tab" ); let bookmark_editor_count = workspace.update(&mut cx, |workspace, cx| { workspace .items_of_type::(cx) .filter(|editor| editor.read(cx).bookmarks_tab_state.is_some()) .count() }); assert_eq!( bookmark_editor_count, 2, "activating an existing tab must not create another one" ); } #[gpui::test] async fn test_bookmarks_tab_highlight_does_not_grow_past_last_row(cx: &mut TestAppContext) { let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2", }), ) .await; toggle_bookmark_on_row(&editor, 2, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); editor.update_in(&mut cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(2, 5)..Point::new(2, 5)]) }); editor.insert("\nline3", window, cx); }); cx.run_until_parked(); assert_eq!( highlighted_display_rows_of(&bookmarks_editor, &mut cx), vec![display_row_for( &bookmarks_editor, Point::new(2, 0), &mut cx )], "appending lines after a last-row bookmark must not stretch its highlight over the new rows" ); } #[gpui::test] async fn test_bookmarks_tab_trailing_line_highlight_does_not_grow_on_edit(cx: &mut TestAppContext) { let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", }), ) .await; toggle_bookmark_on_row(&editor, 2, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); bookmarks_editor.update_in(&mut cx, |editor, window, cx| { let max_point = editor.buffer.read(cx).snapshot(cx).max_point(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([max_point..max_point]) }); editor.insert("new0\nnew1", window, cx); }); cx.run_until_parked(); assert_eq!( highlighted_display_rows_of(&bookmarks_editor, &mut cx), vec![last_display_row(&bookmarks_editor, &mut cx)], "typing at a trailing-line bookmark must move its single-row highlight, not stretch it" ); } #[gpui::test] async fn test_bookmarks_tab_last_line_highlight_does_not_cover_next_file_header( cx: &mut TestAppContext, ) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\nline2", "other.rs": "other0\nother1\n", }), ) .await; toggle_bookmark_on_row(&editor, 2, &mut cx); let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; toggle_bookmark_on_row(&other_editor, 0, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( highlighted_display_rows_of(&bookmarks_editor, &mut cx), vec![ display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx), display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx), ], "a bookmark on a file's last line must not highlight the next file's header rows" ); } #[gpui::test] async fn test_bookmarks_tab_trailing_line_highlight_does_not_cover_next_file_header( cx: &mut TestAppContext, ) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", "other.rs": "other0\n", }), ) .await; toggle_bookmark_on_row(&editor, 2, &mut cx); let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; toggle_bookmark_on_row(&other_editor, 0, &mut cx); cx.run_until_parked(); let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( highlighted_display_rows_of(&bookmarks_editor, &mut cx), vec![ display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx), display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx), ], "a bookmark on a trailing empty line must not highlight the next file's header rows" ); } #[gpui::test] async fn test_worktree_add_retries_only_contained_failed_paths(cx: &mut TestAppContext) { let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", }), ) .await; let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); let elsewhere_path = bookmark_path(path!("/elsewhere/file.rs")); load_test_bookmarks( &bookmark_store, vec![ (bookmark_path(path!("/a/main.rs")), vec![0]), (Arc::clone(&elsewhere_path), vec![0]), ], &mut cx, ) .await; let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 1, "the path outside all worktrees should not produce an excerpt yet" ); assert_eq!( failed_paths(&bookmark_store, &mut cx), vec![Arc::clone(&elsewhere_path)], "the path outside all worktrees should be recorded as failed to open" ); let events = Rc::new(RefCell::new(Vec::::new())); let _subscription = cx.update({ let events = events.clone(); |_window, cx| { cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| { events.borrow_mut().push(event.clone()); }) } }); let fs = project.read_with(&cx, |project, _| project.fs().clone()); fs.as_fake() .insert_tree(path!("/unrelated"), json!({ "other.rs": "other0\n" })) .await; project .update(&mut cx, |project, cx| { project.create_worktree(path!("/unrelated"), true, cx) }) .await .expect("creating the unrelated worktree should succeed"); cx.run_until_parked(); assert_eq!( *events.borrow(), Vec::::new(), "a worktree containing no failed paths must not trigger a retry" ); assert_eq!( failed_paths(&bookmark_store, &mut cx), vec![Arc::clone(&elsewhere_path)], "an unrelated worktree must not clear the failed path cache" ); fs.as_fake() .insert_tree(path!("/elsewhere"), json!({ "file.rs": "else0\n" })) .await; project .update(&mut cx, |project, cx| { project.create_worktree(path!("/elsewhere"), true, cx) }) .await .expect("creating the containing worktree should succeed"); cx.run_until_parked(); assert_eq!( failed_paths(&bookmark_store, &mut cx), Vec::new(), "adding a worktree containing a failed path must retry it" ); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 2, "the bookmarks tab should refresh and show the failed path's bookmark" ); } #[gpui::test] async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestAppContext) { let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( cx, json!({ "main.rs": "line0\nline1\n", "late.rs": {}, }), ) .await; let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); let late_path = bookmark_path(path!("/a/late.rs")); load_test_bookmarks( &bookmark_store, vec![ (bookmark_path(path!("/a/main.rs")), vec![0]), (Arc::clone(&late_path), vec![0]), ], &mut cx, ) .await; let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 1, "the missing file should not produce an excerpt yet" ); assert_eq!( failed_paths(&bookmark_store, &mut cx), vec![Arc::clone(&late_path)], "the missing path should be recorded as failed to open" ); let fs = project.read_with(&cx, |project, _| project.fs().clone()); fs.remove_dir( std::path::Path::new(path!("/a/late.rs")), fs::RemoveOptions { recursive: true, ignore_if_not_exists: false, }, ) .await .expect("removing the placeholder directory should succeed"); fs.as_fake() .insert_file(path!("/a/late.rs"), "late0\n".as_bytes().to_vec()) .await; cx.run_until_parked(); assert_eq!( failed_paths(&bookmark_store, &mut cx), Vec::new(), "a failed path must be retried once its file appears on disk" ); assert_eq!( excerpt_count(&bookmarks_editor, &mut cx), 2, "the bookmarks tab should refresh and show the late file's bookmark" ); } #[gpui::test] async fn test_dynamic_document_highlight_registration_refreshes_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let debounce = Duration::from_millis( cx.update(|_, cx| EditorSettings::get_global(cx).lsp_highlight_debounce.0), ); let project = cx.update_workspace(|workspace, _, _| workspace.project().clone()); let server_id = cx.lsp.server.server_id(); cx.set_state(indoc! {" fn main() { let foo = 1; fˇoo; } "}); cx.executor().advance_clock(debounce); cx.run_until_parked(); let request_count = Arc::new(AtomicUsize::new(0)); let _request_handler = cx.set_request_handler::({ let request_count = request_count.clone(); move |_, _, _| { request_count.fetch_add(1, atomic::Ordering::SeqCst); async move { Ok(Some(vec![lsp::DocumentHighlight { range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 7)), kind: Some(lsp::DocumentHighlightKind::READ), }])) } } }); register_document_highlight_capability(&cx.lsp, "python-document-highlight", "python").await; cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 0, "expected a registration for another language to not query the server", ); assert_eq!(document_highlight_count(&mut cx), 0); register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 1, "expected an applicable registration to refresh document highlights", ); assert_eq!(document_highlight_count(&mut cx), 1); cx.update_editor(|editor, _, cx| { editor.refresh_document_highlights(cx); }); for _ in 0..3 { cx.executor().advance_clock(debounce / 4); cx.run_until_parked(); cx.update(|_, cx| { project.update(cx, |_, cx| { cx.emit(project::Event::RefreshDocumentHighlights { server_id: Some(LanguageServerId(server_id.0 + 1)), }); }); }); cx.run_until_parked(); } cx.executor().advance_clock(debounce / 4); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 2, "expected refreshes of servers unrelated to the buffer to not restart the debounce", ); cx.update(|_, cx| { project.update(cx, |_, cx| { cx.emit(project::Event::RefreshDocumentHighlights { server_id: Some(server_id), }); }); }); cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 3, "expected a refresh of the buffer's server to query document highlights", ); cx.lsp .request::( lsp::UnregistrationParams { unregisterations: vec![lsp::Unregistration { id: "rust-document-highlight".to_string(), method: "textDocument/documentHighlight".to_string(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 3, "expected no server query after the applicable registration is removed", ); assert_eq!( document_highlight_count(&mut cx), 0, "expected stale document highlights to be cleared after unregistration", ); register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!(request_count.load(atomic::Ordering::SeqCst), 4); assert_eq!(document_highlight_count(&mut cx), 1); let buffer = cx.update_editor(|editor, _, cx| editor.buffer().read(cx).as_singleton().unwrap()); cx.update(|_, cx| { project.update(cx, |project, cx| { project.stop_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); }); }); cx.executor().advance_clock(debounce); cx.run_until_parked(); assert_eq!( request_count.load(atomic::Ordering::SeqCst), 4, "expected no document highlight request after the server is stopped", ); assert_eq!( document_highlight_count(&mut cx), 0, "expected stale document highlights to be cleared after the server is stopped", ); } #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); let capabilities = lsp::ServerCapabilities { rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { prepare_provider: Some(true), work_done_progress_options: Default::default(), })), ..Default::default() }; let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; cx.set_state(indoc! {" struct Fˇoo {} "}); cx.update_editor(|editor, _, cx| { let highlight_range = Point::new(0, 7)..Point::new(0, 10); let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background( HighlightKey::DocumentHighlightRead, &[highlight_range], |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); let mut prepare_rename_handler = cx .set_request_handler::( move |_, _, _| async move { Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { start: lsp::Position { line: 0, character: 7, }, end: lsp::Position { line: 0, character: 10, }, }))) }, ); let prepare_rename_task = cx .update_editor(|e, window, cx| e.rename(&Rename, window, cx)) .expect("Prepare rename was not started"); prepare_rename_handler.next().await.unwrap(); prepare_rename_task.await.expect("Prepare rename failed"); let mut rename_handler = cx.set_request_handler::(move |url, _, _| async move { let edit = lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 7, }, end: lsp::Position { line: 0, character: 10, }, }, new_text: "FooRenamed".to_string(), }; Ok(Some(lsp::WorkspaceEdit::new( // Specify the same edit twice std::collections::HashMap::from_iter(Some((url, vec![edit.clone(), edit]))), ))) }); let rename_task = cx .update_editor(|e, window, cx| e.confirm_rename(&ConfirmRename, window, cx)) .expect("Confirm rename was not started"); rename_handler.next().await.unwrap(); rename_task.await.expect("Confirm rename failed"); cx.run_until_parked(); // Despite two edits, only one is actually applied as those are identical cx.assert_editor_state(indoc! {" struct FooRenamedˇ {} "}); } #[gpui::test] async fn test_rename_with_out_of_order_document_highlights(cx: &mut TestAppContext) { init_test(cx, |_| {}); let capabilities = lsp::ServerCapabilities { rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { prepare_provider: Some(true), work_done_progress_options: Default::default(), })), ..Default::default() }; let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; cx.set_state(indoc! {" struct Foo {} fn main() { let first = Foo {}; let second = Fˇoo {}; } "}); cx.update_editor(|editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let read_highlight = (Point::new(2, 16)..Point::new(2, 19)).to_anchors(&snapshot); let write_highlight = (Point::new(3, 17)..Point::new(3, 20)).to_anchors(&snapshot); editor.highlight_background( HighlightKey::DocumentHighlightRead, &[read_highlight], |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); editor.highlight_background( HighlightKey::DocumentHighlightWrite, &[write_highlight], |_, theme| theme.colors().editor_document_highlight_write_background, cx, ); }); let mut prepare_rename_handler = cx .set_request_handler::( move |_, _, _| async move { Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { start: lsp::Position { line: 3, character: 17, }, end: lsp::Position { line: 3, character: 20, }, }))) }, ); let prepare_rename_task = cx .update_editor(|e, window, cx| e.rename(&Rename, window, cx)) .expect("Prepare rename was not started"); prepare_rename_handler.next().await.unwrap(); prepare_rename_task.await.expect("Prepare rename failed"); cx.update_editor(|editor, window, cx| { editor .snapshot(window, cx) .layout_row(DisplayRow(2), &editor.text_layout_details(window, cx)); }); } #[gpui::test] async fn test_rename_without_prepare(cx: &mut TestAppContext) { init_test(cx, |_| {}); // These capabilities indicate that the server does not support prepare rename. let capabilities = lsp::ServerCapabilities { rename_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }; let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; cx.set_state(indoc! {" struct Fˇoo {} "}); cx.update_editor(|editor, _window, cx| { let highlight_range = Point::new(0, 7)..Point::new(0, 10); let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background( HighlightKey::DocumentHighlightRead, &[highlight_range], |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); cx.update_editor(|e, window, cx| e.rename(&Rename, window, cx)) .expect("Prepare rename was not started") .await .expect("Prepare rename failed"); let mut rename_handler = cx.set_request_handler::(move |url, _, _| async move { let edit = lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 7, }, end: lsp::Position { line: 0, character: 10, }, }, new_text: "FooRenamed".to_string(), }; Ok(Some(lsp::WorkspaceEdit::new( std::collections::HashMap::from_iter(Some((url, vec![edit]))), ))) }); let rename_task = cx .update_editor(|e, window, cx| e.confirm_rename(&ConfirmRename, window, cx)) .expect("Confirm rename was not started"); rename_handler.next().await.unwrap(); rename_task.await.expect("Confirm rename failed"); cx.run_until_parked(); // Correct range is renamed, as `surrounding_word` is used to find it. cx.assert_editor_state(indoc! {" struct FooRenamedˇ {} "}); } #[gpui::test] async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig::default(), Some(tree_sitter_html::LANGUAGE.into()), ) .with_brackets_query( r#" ("<" @open "/>" @close) ("" @close) ("<" @open ">" @close) ("\"" @open "\"" @close) ((element (start_tag) @open (end_tag) @close) (#set! newline.only)) "#, ) .unwrap(), ); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" ˇ "}); cx.set_state(indoc! {" ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" ˇ "}); cx.set_state(indoc! {" ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" ˇ "}); } #[gpui::test(iterations = 10)] async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.code_lens = Some(settings::CodeLens::Menu); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }) .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { code_lens_provider: Some(lsp::CodeLensOptions { resolve_provider: Some(true), }), execute_command_provider: Some(lsp::ExecuteCommandOptions { commands: vec!["_the/command".to_string()], ..lsp::ExecuteCommandOptions::default() }), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/dir/a.ts")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); cx.executor().run_until_parked(); let fake_server = fake_language_servers.next().await.unwrap(); let buffer = editor.update(cx, |editor, cx| { editor .buffer() .read(cx) .as_singleton() .expect("have opened a single file by path") }); let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); let anchor = buffer_snapshot.anchor_at(0, text::Bias::Left); drop(buffer_snapshot); let actions = cx .update_window(*window, |_, window, cx| { project.code_actions(&buffer, anchor..anchor, window, cx) }) .unwrap(); fake_server .set_request_handler::(|_, _| async move { Ok(Some(vec![ lsp::CodeLens { range: lsp::Range::default(), command: Some(lsp::Command { title: "Code lens command".to_owned(), command: "_the/command".to_owned(), arguments: None, }), data: None, }, lsp::CodeLens { range: lsp::Range { start: lsp::Position { line: 1, character: 1, }, end: lsp::Position { line: 1, character: 1, }, }, command: Some(lsp::Command { title: "Command not in range".to_owned(), command: "_the/command".to_owned(), arguments: None, }), data: None, }, ])) }) .next() .await; let actions = actions.await.unwrap(); assert_eq!( actions.len(), 1, "Should have only one valid action for the 0..0 range, got: {actions:#?}" ); let action = actions[0].clone(); let apply = project.update(cx, |project, cx| { project.apply_code_action(buffer.clone(), action, true, cx) }); // Resolving the code action does not populate its edits. In absence of // edits, we must execute the given command. fake_server.set_request_handler::( |mut lens, _| async move { let lens_command = lens.command.as_mut().expect("should have a command"); assert_eq!(lens_command.title, "Code lens command"); lens_command.arguments = Some(vec![json!("the-argument")]); Ok(lens) }, ); // While executing the command, the language server sends the editor // a `workspaceEdit` request. fake_server .set_request_handler::({ let fake = fake_server.clone(); move |params, _| { assert_eq!(params.command, "_the/command"); let fake = fake.clone(); async move { fake.server .request::( lsp::ApplyWorkspaceEditParams { label: None, edit: lsp::WorkspaceEdit { changes: Some( [( lsp::Uri::from_file_path(path!("/dir/a.ts")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 0), ), new_text: "X".into(), }], )] .into_iter() .collect(), ), ..lsp::WorkspaceEdit::default() }, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); Ok(Some(json!(null))) } } }) .next() .await; // Applying the code lens command returns a project transaction containing the edits // sent by the language server in its `workspaceEdit` request. let transaction = apply.await.unwrap(); assert!(transaction.0.contains_key(&buffer)); buffer.update(cx, |buffer, cx| { assert_eq!(buffer.text(), "Xa"); buffer.undo(cx); assert_eq!(buffer.text(), "a"); }); let actions_after_edits = cx .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) .unwrap() .await; assert_eq!( actions, actions_after_edits, "For the same selection, same code lens actions should be returned" ); let _responses = fake_server.set_request_handler::(|_, _| async move { panic!("No more code lens requests are expected"); }); editor.update_in(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); }); cx.executor().run_until_parked(); let new_actions = cx .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) .unwrap() .await; assert_eq!( actions, new_actions, "Code lens are queried for the same range and should get the same set back, but without additional LSP queries now" ); } #[gpui::test] async fn test_reveal_if_open_uses_user_setting_for_loaded_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update_global(|store: &mut SettingsStore, cx| { store .set_user_settings(r#"{ "reveal_if_open": true }"#, cx) .unwrap(); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "file.txt": "content" })) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); let worktree_id = project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); let left_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); let original_editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("file.txt")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); let buffer = original_editor.read_with(cx, |editor, cx| { editor.buffer().read(cx).as_singleton().unwrap() }); let right_pane = workspace.update_in(cx, |workspace, window, cx| { let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, window, cx); window.focus(&right_pane.focus_handle(cx), cx); right_pane }); cx.run_until_parked(); workspace.read_with(cx, |workspace, _| { assert_eq!(workspace.active_pane(), &right_pane); }); let revealed_editor = workspace.update_in(cx, |workspace, window, cx| { workspace.open_project_item(None, buffer.clone(), true, true, false, false, window, cx) }); assert_eq!(revealed_editor, original_editor); assert_eq!(left_pane.read_with(cx, |pane, _| pane.items_len()), 1); assert_eq!(right_pane.read_with(cx, |pane, _| pane.items_len()), 0); workspace.read_with(cx, |workspace, _| { assert_eq!(workspace.active_pane(), &left_pane); }); let duplicate_editor = workspace.update_in(cx, |workspace, window, cx| { workspace.open_project_item( Some(right_pane.clone()), buffer, true, true, false, false, window, cx, ) }); assert_ne!(duplicate_editor, original_editor); assert_eq!(right_pane.read_with(cx, |pane, _| pane.items_len()), 1); } #[gpui::test] async fn test_editor_restore_data_different_in_panes(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let main_text = r#"fn main() { println!("1"); println!("2"); println!("3"); println!("4"); println!("5"); }"#; let lib_text = "mod foo {}"; fs.insert_tree( path!("/a"), json!({ "lib.rs": lib_text, "main.rs": main_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let expected_ranges = vec![ Point::new(0, 0)..Point::new(0, 0), Point::new(1, 0)..Point::new(1, 1), Point::new(2, 0)..Point::new(2, 2), Point::new(3, 0)..Point::new(3, 3), ]; let pane_1 = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); let editor_1 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane_1.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane_1.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), main_text, "Original main.rs text on initial open", ); assert_eq!( editor .selections .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), vec![Point::zero()..Point::zero()], "Default selections on initial open", ); }) }); editor_1.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(expected_ranges.clone()); }); }); let pane_2 = workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane(pane_1.clone(), SplitDirection::Right, window, cx) }); let editor_2 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane_2.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane_2.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), main_text, "Original main.rs text on initial open in another panel", ); assert_eq!( editor .selections .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), vec![Point::zero()..Point::zero()], "Default selections on initial open in another panel", ); }) }); editor_2.update_in(cx, |editor, window, cx| { editor.fold_ranges(expected_ranges.clone(), false, window, cx); }); let _other_editor_1 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("lib.rs")), Some(pane_1.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane_1 .update_in(cx, |pane, window, cx| { pane.close_other_items(&CloseOtherItems::default(), None, window, cx) }) .await .unwrap(); drop(editor_1); pane_1.update(cx, |pane, cx| { pane.active_item() .unwrap() .downcast::() .unwrap() .update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), lib_text, "Other file should be open and active", ); }); assert_eq!(pane.items().count(), 1, "No other editors should be open"); }); let _other_editor_2 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("lib.rs")), Some(pane_2.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane_2 .update_in(cx, |pane, window, cx| { pane.close_other_items(&CloseOtherItems::default(), None, window, cx) }) .await .unwrap(); drop(editor_2); pane_2.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), lib_text, "Other file should be open and active in another panel too", ); }); assert_eq!( pane.items().count(), 1, "No other editors should be open in another pane", ); }); let _editor_1_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane_1.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); let _editor_2_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane_2.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane_1.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), main_text, "Previous editor in the 1st panel had no extra text manipulations and should get none on reopen", ); assert_eq!( editor .selections .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), expected_ranges, "Previous editor in the 1st panel had selections and should get them restored on reopen", ); }) }); pane_2.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), r#"fn main() { ⋯rintln!("1"); ⋯intln!("2"); ⋯ntln!("3"); println!("4"); println!("5"); }"#, "Previous editor in the 2nd pane had folds and should restore those on reopen in the same pane", ); assert_eq!( editor .selections .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), vec![Point::zero()..Point::zero()], "Previous editor in the 2nd pane had no selections changed hence should restore none", ); }) }); } #[gpui::test] async fn test_editor_does_not_restore_data_when_turned_off(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let main_text = r#"fn main() { println!("1"); println!("2"); println!("3"); println!("4"); println!("5"); }"#; let lib_text = "mod foo {}"; fs.insert_tree( path!("/a"), json!({ "lib.rs": lib_text, "main.rs": main_text, }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), main_text, "Original main.rs text on initial open", ); }) }); editor.update_in(cx, |editor, window, cx| { editor.fold_ranges(vec![Point::new(0, 0)..Point::new(0, 0)], false, window, cx); }); cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |s| { s.workspace.restore_on_file_reopen = Some(false); }); }); editor.update_in(cx, |editor, window, cx| { editor.fold_ranges( vec![ Point::new(1, 0)..Point::new(1, 1), Point::new(2, 0)..Point::new(2, 2), Point::new(3, 0)..Point::new(3, 3), ], false, window, cx, ); }); pane.update_in(cx, |pane, window, cx| { pane.close_all_items(&CloseAllItems::default(), window, cx) }) .await .unwrap(); pane.update(cx, |pane, _| { assert!(pane.active_item().is_none()); }); cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |s| { s.workspace.restore_on_file_reopen = Some(true); }); }); let _editor_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), main_text, "No folds: even after enabling the restoration, previous editor's data should not be saved to be used for the restoration" ); }) }); } struct EmptyModalView { focus_handle: gpui::FocusHandle, } impl EventEmitter for EmptyModalView {} impl Render for EmptyModalView { fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { div() } } impl Focusable for EmptyModalView { fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { self.focus_handle.clone() } } impl workspace::ModalView for EmptyModalView {} impl EmptyModalView { fn new(cx: &App) -> Self { Self { focus_handle: cx.focus_handle(), } } } #[gpui::test] async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let buffer = cx.update(|cx| MultiBuffer::build_simple("hello world!", cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), buffer, Some(project.clone()), window, cx, ) }); workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); }); editor.update_in(cx, |editor, window, cx| { editor.open_context_menu(&OpenContextMenu, window, cx); assert!(editor.mouse_context_menu.is_some()); }); workspace.update_in(cx, |workspace, window, cx| { workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); }); cx.read(|cx| { assert!(editor.read(cx).mouse_context_menu.is_none()); }); } #[gpui::test] async fn test_hide_pending_blame_popover_when_modal_opens(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); let multi_buffer = cx.update(|cx| MultiBuffer::build_simple("Buffer Contents!", cx)); let buffer_id = multi_buffer.read_with(cx, |multi_buffer, cx| { multi_buffer .all_buffers_iter() .next() .expect("Should have at least one buffer") .read(cx) .remote_id() }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); }); editor.update_in(cx, |editor, _, cx| { editor.blame = Some( cx.new(|cx| GitBlame::new(editor.buffer.clone(), project.clone(), false, true, cx)), ); editor.show_blame_popover( buffer_id, &::git::blame::BlameEntry { sha: "1b1b1b".parse().unwrap(), range: 0..1, original_line_number: 0, author: None, author_mail: None, author_time: None, author_tz: None, committer_name: None, committer_email: None, committer_time: None, committer_tz: None, summary: None, previous: None, filename: String::new(), boundary: false, }, gpui::point(gpui::px(0.), gpui::px(0.)), false, cx, ); assert!(editor.inline_blame_popover_show_task.is_some()); assert!(editor.inline_blame_popover.is_none()); }); workspace.update_in(cx, |workspace, window, cx| { workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); }); // Toggling a modal while the blame popover task is still pending should // clear both the task and any rendered popover. editor.update_in(cx, |editor, _, _| { assert!(editor.inline_blame_popover.is_none()); assert!(editor.inline_blame_popover_show_task.is_none()); }); } fn set_linked_edit_ranges( opening: (Point, Point), closing: (Point, Point), editor: &mut Editor, cx: &mut Context, ) { let Some((buffer, _)) = editor .buffer .read(cx) .text_anchor_for_position(editor.selections.newest_anchor().start, cx) else { panic!("Failed to get buffer for selection position"); }; let buffer = buffer.read(cx); let buffer_id = buffer.remote_id(); let opening_range = buffer.anchor_before(opening.0)..buffer.anchor_after(opening.1); let closing_range = buffer.anchor_before(closing.0)..buffer.anchor_after(closing.1); let mut linked_ranges = HashMap::default(); linked_ranges.insert(buffer_id, vec![(opening_range, vec![closing_range])]); editor.linked_edit_ranges = LinkedEditingRanges(linked_ranges); } #[gpui::test] async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.html"), Default::default()) .await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let html_language = Arc::new(Language::new( LanguageConfig { name: "HTML".into(), matcher: (LanguageMatcher { path_suffixes: vec!["html".to_string()], ..LanguageMatcher::default() }) .into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "<".into(), end: ">".into(), close: true, ..Default::default() }], ..Default::default() }, ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), )); language_registry.add(html_language); let mut fake_servers = language_registry.register_fake_lsp( "HTML", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/file.html"), cx) }) .await .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("file.html")), None, true, window, cx) }) .await .unwrap() .downcast::() .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.set_text("", window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(0, 3)..Point::new(0, 3)]); }); set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 3)), (Point::new(0, 6), Point::new(0, 8)), editor, cx, ); }); let mut completion_handle = fake_server.set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "head".to_string(), text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( lsp::InsertReplaceEdit { new_text: "head".to_string(), insert: lsp::Range::new( lsp::Position::new(0, 1), lsp::Position::new(0, 3), ), replace: lsp::Range::new( lsp::Position::new(0, 1), lsp::Position::new(0, 3), ), }, )), ..Default::default() }, ]))) }); editor.update_in(cx, |editor, window, cx| { editor.show_completions(&ShowCompletions, window, cx); }); cx.run_until_parked(); completion_handle.next().await.unwrap(); editor.update(cx, |editor, _| { assert!( editor.context_menu_visible(), "Completion menu should be visible" ); }); editor.update_in(cx, |editor, window, cx| { editor.confirm_completion(&ConfirmCompletion::default(), window, cx) }); cx.executor().run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), ""); }); } #[gpui::test] async fn test_linked_edits_on_typing_punctuation(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { name: "TSX".into(), matcher: (LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..LanguageMatcher::default() }) .into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "<".into(), end: ">".into(), close: true, ..Default::default() }], ..Default::default() }, linked_edit_characters: HashSet::from_iter(['.']), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // Test typing > does not extend linked pair cx.set_state(""); cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 4)), (Point::new(0, 11), Point::new(0, 14)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input(">", window, cx); }); cx.assert_editor_state("
ˇ
"); // Test typing . do extend linked pair cx.set_state(""); cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 9)), (Point::new(0, 12), Point::new(0, 20)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input(".", window, cx); }); cx.assert_editor_state(""); cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 10)), (Point::new(0, 13), Point::new(0, 21)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input("V", window, cx); }); cx.assert_editor_state(""); } #[gpui::test] async fn test_linked_edits_on_typing_dash_in_custom_element_name(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { name: "TSX".into(), matcher: (LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..LanguageMatcher::default() }) .into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "<".into(), end: ">".into(), close: true, ..Default::default() }], ..Default::default() }, // Mirrors crates/grammars/src/{tsx,javascript}/config.toml: a dash is a valid // JSXIdentifier character, so custom element names like `` must keep // the closing tag linked past the dash. Regression test for #43060 / #58553. linked_edit_characters: HashSet::from_iter(['.', '-']), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // Typing the dash while renaming a tag must extend the linked pair rather than // dropping it (the bug produced ``). cx.set_state(""); cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 7)), (Point::new(0, 10), Point::new(0, 16)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input("-", window, cx); }); cx.assert_editor_state(""); // Characters typed after the dash continue to mirror into the closing tag. cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 8)), (Point::new(0, 11), Point::new(0, 18)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input("el", window, cx); }); cx.assert_editor_state(""); } #[gpui::test] async fn test_linked_edits_on_typing_dot_without_language_override(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { name: "HTML".into(), matcher: (LanguageMatcher { path_suffixes: vec!["html".to_string()], ..LanguageMatcher::default() }) .into(), brackets: BracketPairConfig { pairs: vec![BracketPair { start: "<".into(), end: ">".into(), close: true, ..Default::default() }], ..Default::default() }, ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), )); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(""); cx.update_editor(|editor, _, cx| { set_linked_edit_ranges( (Point::new(0, 1), Point::new(0, 6)), (Point::new(0, 9), Point::new(0, 14)), editor, cx, ); }); cx.update_editor(|editor, window, cx| { editor.handle_input(".", window, cx); }); cx.assert_editor_state(""); } #[gpui::test] async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "a": { "main.rs": "fn main() {}", }, "foo": { "bar": { "external_file.rs": "pub mod external {}", } } }), ) .await; let project = Project::test(fs, [path!("/root/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let _fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { ..FakeLspAdapter::default() }, ); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let assert_language_servers_count = |expected: usize, context: &str, cx: &mut VisualTestContext| { project.update(cx, |project, cx| { let current = project .lsp_store() .read(cx) .as_local() .unwrap() .language_servers .len(); assert_eq!(expected, current, "{context}"); }); }; assert_language_servers_count( 0, "No servers should be running before any file is open", cx, ); let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); let main_editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); pane.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), "fn main() {}", "Original main.rs text on initial open", ); }); assert_eq!(open_editor, main_editor); }); assert_language_servers_count(1, "First *.rs file starts a language server", cx); let external_editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from("/root/foo/bar/external_file.rs"), OpenOptions::default(), window, cx, ) }) .await .expect("opening external file") .downcast::() .expect("downcasted external file's open element to editor"); pane.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), "pub mod external {}", "External file is open now", ); }); assert_eq!(open_editor, external_editor); }); assert_language_servers_count( 1, "Second, external, *.rs file should join the existing server", cx, ); pane.update_in(cx, |pane, window, cx| { pane.close_active_item(&CloseActiveItem::default(), window, cx) }) .await .unwrap(); pane.update_in(cx, |pane, window, cx| { pane.navigate_backward(&Default::default(), window, cx); }); cx.run_until_parked(); pane.update(cx, |pane, cx| { let open_editor = pane.active_item().unwrap().downcast::().unwrap(); open_editor.update(cx, |editor, cx| { assert_eq!( editor.display_text(cx), "pub mod external {}", "External file is open now", ); }); }); assert_language_servers_count( 1, "After closing and reopening (with navigate back) of an external file, no extra language servers should appear", cx, ); cx.update(|_, cx| { workspace::reload(cx); }); assert_language_servers_count( 1, "After reloading the worktree with local and external files opened, only one project should be started", cx, ); } #[gpui::test] async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test cursor move to start of each line on tab // for `if`, `elif`, `else`, `while`, `with` and `for` cx.set_state(indoc! {" def main(): ˇ for item in items: ˇ while item.active: ˇ if item.value > 10: ˇ continue ˇ elif item.value < 0: ˇ break ˇ else: ˇ with item.context() as ctx: ˇ yield count ˇ else: ˇ log('while else') ˇ else: ˇ log('for else') "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇfor item in items: ˇwhile item.active: ˇif item.value > 10: ˇcontinue ˇelif item.value < 0: ˇbreak ˇelse: ˇwith item.context() as ctx: ˇyield count ˇelse: ˇlog('while else') ˇelse: ˇlog('for else') "}); // test relative indent is preserved when tab // for `if`, `elif`, `else`, `while`, `with` and `for` cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇfor item in items: ˇwhile item.active: ˇif item.value > 10: ˇcontinue ˇelif item.value < 0: ˇbreak ˇelse: ˇwith item.context() as ctx: ˇyield count ˇelse: ˇlog('while else') ˇelse: ˇlog('for else') "}); // test cursor move to start of each line on tab // for `try`, `except`, `else`, `finally`, `match` and `def` cx.set_state(indoc! {" def main(): ˇ try: ˇ fetch() ˇ except ValueError: ˇ handle_error() ˇ else: ˇ match value: ˇ case _: ˇ finally: ˇ def status(): ˇ return 0 "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇtry: ˇfetch() ˇexcept ValueError: ˇhandle_error() ˇelse: ˇmatch value: ˇcase _: ˇfinally: ˇdef status(): ˇreturn 0 "}); // test relative indent is preserved when tab // for `try`, `except`, `else`, `finally`, `match` and `def` cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇtry: ˇfetch() ˇexcept ValueError: ˇhandle_error() ˇelse: ˇmatch value: ˇcase _: ˇfinally: ˇdef status(): ˇreturn 0 "}); } #[gpui::test] async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test `else` auto outdents when typed inside `if` block cx.set_state(indoc! {" def main(): if i == 2: return ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): if i == 2: return else:ˇ "}); // Completing `else:` at multiple cursors must still trigger syntax outdents. cx.set_state(indoc! {" def f(): if True: pass elseˇ pass def g(): if True: pass elseˇ pass "}); cx.update_editor(|editor, window, cx| editor.handle_input(":", window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def f(): if True: pass else:ˇ pass def g(): if True: pass else:ˇ pass "}); // test `except` auto outdents when typed inside `try` block cx.set_state(indoc! {" def main(): try: i = 2 ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 except:ˇ "}); // test `else` auto outdents when typed inside `except` block cx.set_state(indoc! {" def main(): try: i = 2 except: j = 2 ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 except: j = 2 else:ˇ "}); // test `finally` auto outdents when typed inside `else` block cx.set_state(indoc! {" def main(): try: i = 2 except: j = 2 else: k = 2 ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("finally:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 except: j = 2 else: k = 2 finally:ˇ "}); // test `else` does not outdents when typed inside `except` block right after for block cx.set_state(indoc! {" def main(): try: i = 2 except: for i in range(n): pass ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 except: for i in range(n): pass else:ˇ "}); // test `finally` auto outdents when typed inside `else` block right after for block cx.set_state(indoc! {" def main(): try: i = 2 except: j = 2 else: for i in range(n): pass ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("finally:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 except: j = 2 else: for i in range(n): pass finally:ˇ "}); // test `except` outdents to inner "try" block cx.set_state(indoc! {" def main(): try: i = 2 if i == 2: try: i = 3 ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 if i == 2: try: i = 3 except:ˇ "}); // test `except` outdents to outer "try" block cx.set_state(indoc! {" def main(): try: i = 2 if i == 2: try: i = 3 ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: i = 2 if i == 2: try: i = 3 except:ˇ "}); // test `else` stays at correct indent when typed after `for` block cx.set_state(indoc! {" def main(): for i in range(10): if i == 3: break ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): for i in range(10): if i == 3: break else:ˇ "}); // test does not outdent on typing after line with square brackets cx.set_state(indoc! {" def f() -> list[str]: ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("a", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def f() -> list[str]: aˇ "}); // test does not outdent on typing : after case keyword cx.set_state(indoc! {" match 1: caseˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input(":", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" match 1: case:ˇ "}); } #[gpui::test] async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_language_settings(cx, &|settings| { settings.defaults.extend_comment_on_newline = Some(false); }); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test correct indent after newline on comment cx.set_state(indoc! {" # COMMENT:ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" # COMMENT: ˇ "}); // test correct indent after newline in brackets cx.set_state(indoc! {" {ˇ} "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" { ˇ } "}); cx.set_state(indoc! {" (ˇ) "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" ( ˇ ) "}); // do not indent after empty lists or dictionaries cx.set_state(indoc! {" a = []ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" a = [] ˇ "}); } #[gpui::test] async fn test_python_indent_in_markdown(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into()); language_registry.add(markdown_lang()); language_registry.add(python_lang); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| { buffer.set_language_registry(language_registry); buffer.set_language(Some(markdown_lang()), cx); }); // Test that `else:` correctly outdents to match `if:` inside the Python code block cx.set_state(indoc! {" # Heading ```python def main(): if condition: pass ˇ ``` "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" # Heading ```python def main(): if condition: pass else:ˇ ``` "}); } #[gpui::test] async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test cursor move to start of each line on tab // for `if`, `elif`, `else`, `while`, `for`, `case` and `function` cx.set_state(indoc! {" function main() { ˇ for item in $items; do ˇ while [ -n \"$item\" ]; do ˇ if [ \"$value\" -gt 10 ]; then ˇ continue ˇ elif [ \"$value\" -lt 0 ]; then ˇ break ˇ else ˇ echo \"$item\" ˇ fi ˇ done ˇ done ˇ} "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { ˇfor item in $items; do ˇwhile [ -n \"$item\" ]; do ˇif [ \"$value\" -gt 10 ]; then ˇcontinue ˇelif [ \"$value\" -lt 0 ]; then ˇbreak ˇelse ˇecho \"$item\" ˇfi ˇdone ˇdone ˇ} "}); // test relative indent is preserved when tab cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { ˇfor item in $items; do ˇwhile [ -n \"$item\" ]; do ˇif [ \"$value\" -gt 10 ]; then ˇcontinue ˇelif [ \"$value\" -lt 0 ]; then ˇbreak ˇelse ˇecho \"$item\" ˇfi ˇdone ˇdone ˇ} "}); // test cursor move to start of each line on tab // for `case` statement with patterns cx.set_state(indoc! {" function handle() { ˇ case \"$1\" in ˇ start) ˇ echo \"a\" ˇ ;; ˇ stop) ˇ echo \"b\" ˇ ;; ˇ *) ˇ echo \"c\" ˇ ;; ˇ esac ˇ} "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function handle() { ˇcase \"$1\" in ˇstart) ˇecho \"a\" ˇ;; ˇstop) ˇecho \"b\" ˇ;; ˇ*) ˇecho \"c\" ˇ;; ˇesac ˇ} "}); } #[gpui::test] async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(language), cx); buffer.set_sync_parse_timeout(None); }); // test indents on comment insert cx.set_state(indoc! {" function main() { ˇ for item in $items; do ˇ while [ -n \"$item\" ]; do ˇ if [ \"$value\" -gt 10 ]; then ˇ continue ˇ elif [ \"$value\" -lt 0 ]; then ˇ break ˇ else ˇ echo \"$item\" ˇ fi ˇ done ˇ done ˇ} "}); cx.update_editor(|e, window, cx| e.handle_input("#", window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { #ˇ for item in $items; do #ˇ while [ -n \"$item\" ]; do #ˇ if [ \"$value\" -gt 10 ]; then #ˇ continue #ˇ elif [ \"$value\" -lt 0 ]; then #ˇ break #ˇ else #ˇ echo \"$item\" #ˇ fi #ˇ done #ˇ done #ˇ} "}); } #[gpui::test] async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test `else` auto outdents when typed inside `if` block cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("else", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" elseˇ "}); // test `elif` auto outdents when typed inside `if` block cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("elif", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" elifˇ "}); // test `fi` auto outdents when typed inside `else` block cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" else echo \"bar baz\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("fi", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" else echo \"bar baz\" fiˇ "}); // test `done` auto outdents when typed inside `while` block cx.set_state(indoc! {" while read line; do echo \"$line\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("done", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" while read line; do echo \"$line\" doneˇ "}); // test `done` auto outdents when typed inside `for` block cx.set_state(indoc! {" for file in *.txt; do cat \"$file\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("done", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" for file in *.txt; do cat \"$file\" doneˇ "}); // test `esac` auto outdents when typed inside `case` block cx.set_state(indoc! {" case \"$1\" in start) echo \"foo bar\" ;; stop) echo \"bar baz\" ;; ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("esac", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) echo \"foo bar\" ;; stop) echo \"bar baz\" ;; esacˇ "}); // test `*)` auto outdents when typed inside `case` block cx.set_state(indoc! {" case \"$1\" in start) echo \"foo bar\" ;; ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("*)", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) echo \"foo bar\" ;; *)ˇ "}); // test `fi` outdents to correct level with nested if blocks cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"outer if\" if [ \"$2\" = \"debug\" ]; then echo \"inner if\" ˇ "}); cx.update_editor(|editor, window, cx| { editor.handle_input("fi", window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"outer if\" if [ \"$2\" = \"debug\" ]; then echo \"inner if\" fiˇ "}); } #[gpui::test] async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_language_settings(cx, &|settings| { settings.defaults.extend_comment_on_newline = Some(false); }); let mut cx = EditorTestContext::new(cx).await; let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); // test correct indent after newline on comment cx.set_state(indoc! {" # COMMENT:ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" # COMMENT: ˇ "}); // test correct indent after newline after `then` cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; thenˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then ˇ "}); // test correct indent after newline after `else` cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then elseˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then else ˇ "}); // test correct indent after newline after `elif` cx.set_state(indoc! {" if [ \"$1\" = \"test\" ]; then elifˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then elif ˇ "}); // test correct indent after newline after `do` cx.set_state(indoc! {" for file in *.txt; doˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" for file in *.txt; do ˇ "}); // test correct indent after newline after case pattern cx.set_state(indoc! {" case \"$1\" in start)ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) ˇ "}); // test correct indent after newline after case pattern cx.set_state(indoc! {" case \"$1\" in start) ;; *)ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) ;; *) ˇ "}); // test correct indent after newline after function opening brace cx.set_state(indoc! {" function test() {ˇ} "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function test() { ˇ } "}); // test no extra indent after semicolon on same line cx.set_state(indoc! {" echo \"test\";ˇ "}); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" echo \"test\"; ˇ "}); } fn empty_range(row: usize, column: usize) -> Range { let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); point..point } #[track_caller] fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context) { let (text, ranges) = marked_text_ranges(marked_text, true); assert_eq!(editor.text(cx), text); assert_eq!( editor.selections.ranges(&editor.display_snapshot(cx)), ranges .iter() .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) .collect::>(), "Assert selections are {}", marked_text ); } pub fn handle_signature_help_request( cx: &mut EditorLspTestContext, mocked_response: lsp::SignatureHelp, ) -> impl Future + use<> { let mut request = cx.set_request_handler::(move |_, _, _| { let mocked_response = mocked_response.clone(); async move { Ok(Some(mocked_response)) } }); async move { request.next().await; } } #[track_caller] pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) { cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() { let entries = menu.entries.borrow(); let entries = entries .iter() .filter_map(|entry| entry.as_match()) .map(|m| m.string.as_str()) .collect::>(); assert_eq!(entries, expected); } else { panic!("Expected completions menu"); } }); } #[gpui::test] async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { ..Default::default() }), ..Default::default() }, cx, ) .await; cx.lsp .set_request_handler::(move |_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "unsafe".into(), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 9, }, end: lsp::Position { line: 0, character: 11, }, }, new_text: "unsafe".to_string(), })), insert_text_mode: Some(lsp::InsertTextMode::AS_IS), ..Default::default() }, ]))) }); cx.update_editor(|editor, _, cx| { editor.project().unwrap().update(cx, |project, cx| { project.snippets().update(cx, |snippets, _cx| { snippets.add_snippet_for_test( None, PathBuf::from("test_snippets.json"), vec![ Arc::new(project::snippet_provider::Snippet { prefix: vec![ "unlimited word count".to_string(), "unlimit word count".to_string(), "unlimited unknown".to_string(), ], body: "this is many words".to_string(), description: Some("description".to_string()), name: "multi-word snippet test".to_string(), }), Arc::new(project::snippet_provider::Snippet { prefix: vec!["unsnip".to_string(), "@few".to_string()], body: "fewer words".to_string(), description: Some("alt description".to_string()), name: "other name".to_string(), }), Arc::new(project::snippet_provider::Snippet { prefix: vec!["ab aa".to_string()], body: "abcd".to_string(), description: None, name: "alphabet".to_string(), }), ], ); }); }) }); let get_completions = |cx: &mut EditorLspTestContext| { cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() { Some(CodeContextMenu::Completions(context_menu)) => { let entries = context_menu.entries.borrow(); entries .iter() .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) .collect_vec() } _ => vec![], }) }; // snippets: // @foo // foo bar // // when typing: // // when typing: // - if I type a symbol "open the completions with snippets only" // - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out) // // stuff we need: // - filtering logic change? // - remember how far back the completion started. let test_cases: &[(&str, &[&str])] = &[ ( "un", &[ "unsafe", "unlimit word count", "unlimited unknown", "unlimited word count", "unsnip", ], ), ( "u ", &[ "unlimit word count", "unlimited unknown", "unlimited word count", ], ), ("u a", &["ab aa", "unsafe"]), // unsAfe ( "u u", &[ "unsafe", "unlimit word count", "unlimited unknown", // ranked highest among snippets "unlimited word count", "unsnip", ], ), ("uw c", &["unlimit word count", "unlimited word count"]), ( "u w", &[ "unlimit word count", "unlimited word count", "unlimited unknown", ], ), ("u w ", &["unlimit word count", "unlimited word count"]), ( "u ", &[ "unlimit word count", "unlimited unknown", "unlimited word count", ], ), ("wor", &[]), ("uf", &["unsafe"]), ("af", &["unsafe"]), ("afu", &[]), ( "ue", &["unsafe", "unlimited unknown", "unlimited word count"], ), ("@", &["@few"]), ("@few", &["@few"]), ("@ ", &[]), ("a@", &["@few"]), ("a@f", &["@few", "unsafe"]), ("a@fw", &["@few"]), ("a", &["ab aa", "unsafe"]), ("aa", &["ab aa"]), ("aaa", &["ab aa"]), ("ab", &["ab aa"]), ("ab ", &["ab aa"]), ("ab a", &["ab aa", "unsafe"]), ("ab ab", &["ab aa"]), ("ab ab aa", &["ab aa"]), ]; for &(input_to_simulate, expected_completions) in test_cases { cx.set_state("fn a() { ˇ }\n"); for c in input_to_simulate.split("") { cx.simulate_input(c); cx.run_until_parked(); } let expected_completions = expected_completions .iter() .map(|s| s.to_string()) .collect_vec(); assert_eq!( get_completions(&mut cx), expected_completions, "< actual / expected >, input = {input_to_simulate:?}", ); } } /// 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. /// /// Also see `handle_completion_request_with_insert_and_replace`. #[track_caller] pub fn handle_completion_request( marked_string: &str, completions: Vec<&'static str>, is_incomplete: bool, counter: Arc, cx: &mut EditorLspTestContext, ) -> impl Future { let complete_from_marker: TextRangeMarker = '|'.into(); let replace_range_marker: TextRangeMarker = ('<', '>').into(); let (_, mut marked_ranges) = marked_text_ranges_by( marked_string, vec![complete_from_marker.clone(), replace_range_marker.clone()], ); let complete_from_position = cx.to_lsp(MultiBufferOffset( marked_ranges.remove(&complete_from_marker).unwrap()[0].start, )); let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); let replace_range = cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); let mut request = cx.set_request_handler::(move |url, params, _| { let completions = completions.clone(); counter.fetch_add(1, atomic::Ordering::Release); async move { assert_eq!(params.text_document_position.text_document.uri, url.clone()); assert_eq!( params.text_document_position.position, complete_from_position ); Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete, item_defaults: None, items: completions .iter() .map(|completion_text| lsp::CompletionItem { label: completion_text.to_string(), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: replace_range, new_text: completion_text.to_string(), })), ..Default::default() }) .collect(), }))) } }); async move { request.next().await; } } /// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be /// given instead, which also contains an `insert` range. /// /// This function uses markers to define ranges: /// - `|` marks the cursor position /// - `<>` marks the replace range /// - `[]` marks the insert range (optional, defaults to `replace_range.start..cursor_pos`which is what Rust-Analyzer provides) pub fn handle_completion_request_with_insert_and_replace( cx: &mut EditorLspTestContext, marked_string: &str, completions: Vec<(&'static str, &'static str)>, // (label, new_text) counter: Arc, ) -> impl Future { let complete_from_marker: TextRangeMarker = '|'.into(); let replace_range_marker: TextRangeMarker = ('<', '>').into(); let insert_range_marker: TextRangeMarker = ('{', '}').into(); let (_, mut marked_ranges) = marked_text_ranges_by( marked_string, vec![ complete_from_marker.clone(), replace_range_marker.clone(), insert_range_marker.clone(), ], ); let complete_from_position = cx.to_lsp(MultiBufferOffset( marked_ranges.remove(&complete_from_marker).unwrap()[0].start, )); let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); let replace_range = cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); let insert_range = match marked_ranges.remove(&insert_range_marker) { Some(ranges) if !ranges.is_empty() => { let range1 = ranges[0].clone(); cx.to_lsp_range(MultiBufferOffset(range1.start)..MultiBufferOffset(range1.end)) } _ => lsp::Range { start: replace_range.start, end: complete_from_position, }, }; let mut request = cx.set_request_handler::(move |url, params, _| { let completions = completions.clone(); counter.fetch_add(1, atomic::Ordering::Release); async move { assert_eq!(params.text_document_position.text_document.uri, url.clone()); assert_eq!( params.text_document_position.position, complete_from_position, "marker `|` position doesn't match", ); Ok(Some(lsp::CompletionResponse::Array( completions .iter() .map(|(label, new_text)| lsp::CompletionItem { label: label.to_string(), text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( lsp::InsertReplaceEdit { insert: insert_range, replace: replace_range, new_text: new_text.to_string(), }, )), ..Default::default() }) .collect(), ))) } }); async move { request.next().await; } } fn handle_resolve_completion_request( cx: &mut EditorLspTestContext, edits: Option>, ) -> impl Future { let edits = edits.map(|edits| { edits .iter() .map(|(marked_string, new_text)| { let (_, marked_ranges) = marked_text_ranges(marked_string, false); let replace_range = cx.to_lsp_range( MultiBufferOffset(marked_ranges[0].start) ..MultiBufferOffset(marked_ranges[0].end), ); lsp::TextEdit::new(replace_range, new_text.to_string()) }) .collect::>() }); let mut request = cx.set_request_handler::(move |_, _, _| { let edits = edits.clone(); async move { Ok(lsp::CompletionItem { additional_text_edits: edits, ..Default::default() }) } }); async move { request.next().await; } } pub(crate) fn update_test_language_settings( cx: &mut TestAppContext, f: &dyn Fn(&mut AllLanguageSettingsContent), ) { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, &|settings: &mut SettingsContent| { f(&mut settings.project.all_languages) }); }); }); } pub(crate) fn update_test_project_settings( cx: &mut TestAppContext, f: &dyn Fn(&mut ProjectSettingsContent), ) { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| f(&mut settings.project)); }); }); } pub(crate) fn update_test_editor_settings( cx: &mut TestAppContext, f: &dyn Fn(&mut EditorSettingsContent), ) { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| f(&mut settings.editor)); }) }) } pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { cx.update(|cx| { assets::Assets.load_test_fonts(cx); let store = SettingsStore::test(cx); cx.set_global(store); theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); zlog::init_test(); update_test_language_settings(cx, &f); } #[track_caller] fn assert_hunk_revert( not_reverted_text_with_selections: &str, expected_hunk_statuses_before: Vec, expected_reverted_text_with_selections: &str, base_text: &str, cx: &mut EditorLspTestContext, ) { cx.set_state(not_reverted_text_with_selections); cx.set_head_text(base_text); cx.executor().run_until_parked(); let actual_hunk_statuses_before = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let reverted_hunk_statuses = snapshot .buffer_snapshot() .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) .map(|hunk| hunk.status().kind) .collect::>(); editor.git_restore(&Default::default(), window, cx); reverted_hunk_statuses }); cx.executor().run_until_parked(); cx.assert_editor_state(expected_reverted_text_with_selections); assert_eq!(actual_hunk_statuses_before, expected_hunk_statuses_before); } #[gpui::test(iterations = 10)] async fn test_pulling_diagnostics(cx: &mut TestAppContext) { init_test(cx, |_| {}); let diagnostic_requests = Arc::new(AtomicUsize::new(0)); let counter = diagnostic_requests.clone(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "first.rs": "fn main() { let a = 5; }", "second.rs": "// Test file", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { identifier: None, inter_file_dependencies: true, workspace_diagnostics: true, work_done_progress_options: Default::default(), }, )), ..Default::default() }, ..Default::default() }, ); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/a/first.rs")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); let fake_server = fake_servers.next().await.unwrap(); let server_id = fake_server.server.server_id(); let mut first_request = fake_server .set_request_handler::(move |params, _| { let new_result_id = counter.fetch_add(1, atomic::Ordering::Release) + 1; let result_id = Some(new_result_id.to_string()); assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/a/first.rs")).unwrap() ); async move { Ok(lsp::DocumentDiagnosticReportResult::Report( lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { items: Vec::new(), result_id, }, }), )) } }); let ensure_result_id = |expected_result_id: Option, cx: &mut TestAppContext| { project.update(cx, |project, cx| { let buffer_id = editor .read(cx) .buffer() .read(cx) .as_singleton() .expect("created a singleton buffer") .read(cx) .remote_id(); let buffer_result_id = project .lsp_store() .read(cx) .result_id_for_buffer_pull(server_id, buffer_id, &None, cx); assert_eq!(expected_result_id, buffer_result_id); }); }; ensure_result_id(None, cx); cx.executor().advance_clock(Duration::from_millis(60)); cx.executor().run_until_parked(); assert_eq!( diagnostic_requests.load(atomic::Ordering::Acquire), 1, "Opening file should trigger diagnostic request" ); first_request .next() .await .expect("should have sent the first diagnostics pull request"); ensure_result_id(Some(SharedString::new_static("1")), cx); // Editing should trigger diagnostics editor.update_in(cx, |editor, window, cx| { editor.handle_input("2", window, cx) }); cx.executor().advance_clock(Duration::from_millis(60)); cx.executor().run_until_parked(); assert_eq!( diagnostic_requests.load(atomic::Ordering::Acquire), 2, "Editing should trigger diagnostic request" ); ensure_result_id(Some(SharedString::new_static("2")), cx); // Moving cursor should not trigger diagnostic request editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) }); }); cx.executor().advance_clock(Duration::from_millis(60)); cx.executor().run_until_parked(); assert_eq!( diagnostic_requests.load(atomic::Ordering::Acquire), 2, "Cursor movement should not trigger diagnostic request" ); ensure_result_id(Some(SharedString::new_static("2")), cx); // Multiple rapid edits should be debounced for _ in 0..5 { editor.update_in(cx, |editor, window, cx| { editor.handle_input("x", window, cx) }); } cx.executor().advance_clock(Duration::from_millis(60)); cx.executor().run_until_parked(); let final_requests = diagnostic_requests.load(atomic::Ordering::Acquire); assert!( final_requests <= 4, "Multiple rapid edits should be debounced (got {final_requests} requests)", ); ensure_result_id(Some(SharedString::new(final_requests.to_string())), cx); } #[gpui::test] async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppContext) { // Regression test for issue #11671 // Previously, adding a cursor after moving multiple cursors would reset // the cursor count instead of adding to the existing cursors. init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // Create a simple buffer with cursor at start cx.set_state(indoc! {" ˇaaaa bbbb cccc dddd eeee ffff gggg hhhh"}); // Add 2 cursors below (so we have 3 total) cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); editor.add_selection_below(&Default::default(), window, cx); }); // Verify we have 3 cursors let initial_count = cx.update_editor(|editor, _, _| editor.selections.count()); assert_eq!( initial_count, 3, "Should have 3 cursors after adding 2 below" ); // Move down one line cx.update_editor(|editor, window, cx| { editor.move_down(&MoveDown, window, cx); }); // Add another cursor below cx.update_editor(|editor, window, cx| { editor.add_selection_below(&Default::default(), window, cx); }); // Should now have 4 cursors (3 original + 1 new) let final_count = cx.update_editor(|editor, _, _| editor.selections.count()); assert_eq!( final_count, 4, "Should have 4 cursors after moving and adding another" ); } #[gpui::test] async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc!( r#"ˇThis is a very long line that will be wrapped when soft wrapping is enabled Second line here"# )); cx.update_editor(|editor, window, cx| { // Enable soft wrapping with a narrow width to force soft wrapping and // confirm that more than 2 rows are being displayed. editor.set_wrap_width(Some(100.0.into()), cx); assert!(editor.display_text(cx).lines().count() > 2); editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(8), 0), ] ); editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: true, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: false, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), ] ); editor.add_selection_above( &AddSelectionAbove { skip_soft_wrap: false, }, window, cx, ); assert_eq!( display_ranges(editor, cx), &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] ); }); // Set up text where selections are in the middle of a soft-wrapped line. // When adding selection below with `skip_soft_wrap` set to `true`, the new // selection should be at the same buffer column, not the same pixel // position. cx.set_state(indoc!( r#"1. Very long line to show «howˇ» a wrapped line would look 2. Very long line to show how a wrapped line would look"# )); cx.update_editor(|editor, window, cx| { // Enable soft wrapping with a narrow width to force soft wrapping and // confirm that more than 2 rows are being displayed. editor.set_wrap_width(Some(100.0.into()), cx); assert!(editor.display_text(cx).lines().count() > 2); editor.add_selection_below( &AddSelectionBelow { skip_soft_wrap: true, }, window, cx, ); // Assert that there's now 2 selections, both selecting the same column // range in the buffer row. let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = editor.selections.all::(&display_map); assert_eq!(selections.len(), 2); assert_eq!(selections[0].start.column, selections[1].start.column); assert_eq!(selections[0].end.column, selections[1].end.column); }); } #[gpui::test] async fn test_insert_snippet(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, _, cx| { editor.project().unwrap().update(cx, |project, cx| { project.snippets().update(cx, |snippets, _cx| { let snippet = project::snippet_provider::Snippet { prefix: vec![], // no prefix needed! body: "an Unspecified".to_string(), description: Some("shhhh it's a secret".to_string()), name: "super secret snippet".to_string(), }; snippets.add_snippet_for_test( None, PathBuf::from("test_snippets.json"), vec![Arc::new(snippet)], ); let snippet = project::snippet_provider::Snippet { prefix: vec![], // no prefix needed! body: " Location".to_string(), description: Some("the word 'location'".to_string()), name: "location word".to_string(), }; snippets.add_snippet_for_test( Some("Markdown".to_string()), PathBuf::from("test_snippets.json"), vec![Arc::new(snippet)], ); }); }) }); cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#)); cx.update_editor(|editor, window, cx| { editor.insert_snippet_at_selections( &InsertSnippet { language: None, name: Some("super secret snippet".to_string()), snippet: None, }, window, cx, ); // Language is specified in the action, // so the buffer language does not need to match editor.insert_snippet_at_selections( &InsertSnippet { language: Some("Markdown".to_string()), name: Some("location word".to_string()), snippet: None, }, window, cx, ); editor.insert_snippet_at_selections( &InsertSnippet { language: None, name: None, snippet: Some("$0 after".to_string()), }, window, cx, ); }); cx.assert_editor_state( r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#, ); } #[gpui::test] async fn test_inlay_hints_request_timeout(cx: &mut TestAppContext) { use crate::inlays::inlay_hints::tests::{cached_hint_labels, init_test, visible_hint_labels}; use settings::InlayHintSettingsContent; use std::sync::atomic::AtomicU32; use std::time::Duration; const BASE_TIMEOUT: Duration = Duration::from_secs(1); let request_count = Arc::new(AtomicU32::new(0)); let closure_request_count = request_count.clone(); init_test(cx, &|settings| { settings.defaults.inlay_hints = Some(InlayHintSettingsContent { enabled: Some(true), ..InlayHintSettingsContent::default() }) }); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, &|settings: &mut SettingsContent| { settings.global_lsp_settings = Some(GlobalLspSettingsContent { request_timeout: Some(BASE_TIMEOUT.as_secs()), max_buffer_line_length: None, button: Some(true), notifications: None, semantic_token_rules: None, }); }); }); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/a"), json!({ "main.rs": "fn main() { let a = 5; }", }), ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { let request_count = closure_request_count.clone(); fake_server.set_request_handler::( move |params, cx| { let request_count = request_count.clone(); async move { cx.background_executor().timer(BASE_TIMEOUT * 2).await; let count = request_count.fetch_add(1, atomic::Ordering::Release) + 1; assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), ); Ok(Some(vec![lsp::InlayHint { position: lsp::Position::new(0, 1), label: lsp::InlayHintLabel::String(count.to_string()), kind: None, text_edits: None, tooltip: None, padding_left: None, padding_right: None, data: None, }])) } }, ); })), ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) }) .await .unwrap(); let editor = cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); cx.executor().run_until_parked(); let fake_server = fake_servers.next().await.unwrap(); cx.executor() .advance_clock(BASE_TIMEOUT + Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { assert!( cached_hint_labels(editor, cx).is_empty(), "First request should time out, no hints cached" ); }) .unwrap(); fake_server .request::((), lsp::DEFAULT_LSP_REQUEST_TIMEOUT) .await .into_response() .unwrap(); cx.executor() .advance_clock(BASE_TIMEOUT + Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { assert!( cached_hint_labels(editor, cx).is_empty(), "Second request should also time out with BASE_TIMEOUT, no hints cached" ); }) .unwrap(); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.global_lsp_settings = Some(GlobalLspSettingsContent { request_timeout: Some(BASE_TIMEOUT.as_secs() * 4), max_buffer_line_length: None, button: Some(true), notifications: None, semantic_token_rules: None, }); }); }); }); fake_server .request::((), lsp::DEFAULT_LSP_REQUEST_TIMEOUT) .await .into_response() .unwrap(); cx.executor() .advance_clock(BASE_TIMEOUT * 4 + Duration::from_millis(100)); cx.executor().run_until_parked(); editor .update(cx, |editor, _window, cx| { assert_eq!( vec!["1".to_string()], cached_hint_labels(editor, cx), "With extended timeout (BASE * 4), hints should arrive successfully" ); assert_eq!(vec!["1".to_string()], visible_hint_labels(editor, cx)); }) .unwrap(); } #[gpui::test] async fn test_click_on_parameter_inlay_hint_places_cursor_correctly(cx: &mut TestAppContext) { use crate::inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}; let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, cx, ) .await; cx.update(|_, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, &|settings: &mut SettingsContent| { settings.project.all_languages.defaults.inlay_hints = Some(InlayHintSettingsContent { enabled: Some(true), show_parameter_hints: Some(true), show_type_hints: Some(true), edit_debounce_ms: Some(0), scroll_debounce_ms: Some(0), ..Default::default() }) }); }); }); cx.set_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); // Buffer: `fn foo(value: i32) {} fn main() { foo(42); }` // The parameter hint "value:" appears before "42" let hint_start_offset = cx.ranges("fn foo(value: i32) {} fn main() { foo(ˇ42); }")[0].start; let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); let hint_label = "value:"; let expected_uri = cx.buffer_lsp_url.clone(); cx.lsp .set_request_handler::(move |params, _| { let expected_uri = expected_uri.clone(); async move { assert_eq!(params.text_document.uri, expected_uri); Ok(Some(vec![lsp::InlayHint { position: hint_position, label: lsp::InlayHintLabel::String(hint_label.to_string()), kind: Some(lsp::InlayHintKind::PARAMETER), text_edits: None, tooltip: None, padding_left: None, padding_right: Some(true), data: None, }])) } }) .next() .await; cx.background_executor.run_until_parked(); cx.update_editor(|editor, _window, cx| { let expected_labels = vec!["value: ".to_string()]; assert_eq!(expected_labels, cached_hint_labels(editor, cx)); assert_eq!(expected_labels, visible_hint_labels(editor, cx)); }); // The cursor is at `4` in `42`. The parameter hint "value: " appears just // before it in display space. We'll click a few characters to the left of // the cursor position to land inside the inlay hint text. let cursor_display_point = cx.update_editor(|editor, _window, cx| { editor .selections .newest_display(&editor.display_snapshot(cx)) .head() }); let cursor_pixel = cx.pixel_position_for(cursor_display_point); let em_width = cx.update_editor(|editor, _, _| editor.last_position_map.as_ref().unwrap().em_layout_width); // Click 3 characters to the left of the cursor, which lands inside the // "value: " inlay hint text. let click_position = gpui::Point { x: cursor_pixel.x - em_width * 3.0, y: cursor_pixel.y, }; cx.simulate_click(click_position, Modifiers::none()); cx.background_executor.run_until_parked(); // The cursor should be placed after the `(`, at the `4` in `42`, // NOT before the `(`. cx.assert_editor_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); } #[gpui::test] async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (editor, cx) = cx.add_window_view(Editor::single_line); editor.update_in(cx, |editor, window, cx| { editor.set_text("oops\n\nwow\n", window, cx) }); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.display_text(cx), "oops⋯⋯wow⋯"); }); editor.update(cx, |editor, cx| { editor.edit([(MultiBufferOffset(3)..MultiBufferOffset(5), "")], cx) }); cx.run_until_parked(); editor.update(cx, |editor, cx| { assert_eq!(editor.display_text(cx), "oop⋯wow⋯"); }); } #[gpui::test] async fn test_non_utf_8_opens(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { register_project_item::(cx); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/root1", json!({})).await; fs.insert_file("/root1/one.pdf", vec![0xff, 0xfe, 0xfd]) .await; let project = Project::test(fs, ["/root1".as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); let handle = workspace .update_in(cx, |workspace, window, cx| { let project_path = (worktree_id, rel_path("one.pdf")); workspace.open_path(project_path, None, true, window, cx) }) .await .unwrap(); // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM. // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8. // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor. assert_eq!(handle.to_any_view().entity_type(), TypeId::of::()); } #[gpui::test] async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); // Test hierarchical sibling navigation let text = r#" fn outer() { if condition { let a = 1; } let b = 2; } fn another() { let c = 3; } "#; let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); // Wait for parsing to complete editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { // Start by selecting "let a = 1;" inside the if block editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26) ]); }); let initial_selection = editor .selections .display_ranges(&editor.display_snapshot(cx)); assert_eq!(initial_selection.len(), 1, "Should have one selection"); // Test select next sibling - should move up levels to find the next sibling // Since "let a = 1;" has no siblings in the if block, it should move up // to find "let b = 2;" which is a sibling of the if block editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); let next_selection = editor .selections .display_ranges(&editor.display_snapshot(cx)); // Should have a selection and it should be different from the initial assert_eq!( next_selection.len(), 1, "Should have one selection after next" ); assert_ne!( next_selection[0], initial_selection[0], "Next sibling selection should be different" ); // Test hierarchical navigation by going to the end of the current function // and trying to navigate to the next function editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22) ]); }); editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); let function_next_selection = editor .selections .display_ranges(&editor.display_snapshot(cx)); // Should move to the next function assert_eq!( function_next_selection.len(), 1, "Should have one selection after function next" ); // Test select previous sibling navigation editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx); let prev_selection = editor .selections .display_ranges(&editor.display_snapshot(cx)); // Should have a selection and it should be different assert_eq!( prev_selection.len(), 1, "Should have one selection after prev" ); assert_ne!( prev_selection[0], function_next_selection[0], "Previous sibling selection should be different from next" ); }); } #[gpui::test] async fn test_next_prev_document_highlight(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state( "let ˇvariable = 42; let another = variable + 1; let result = variable * 2;", ); // Set up document highlights manually (simulating LSP response) cx.update_editor(|editor, _window, cx| { let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); // Create highlights for "variable" occurrences let highlight_ranges = [ Point::new(0, 4)..Point::new(0, 12), // First "variable" Point::new(1, 14)..Point::new(1, 22), // Second "variable" Point::new(2, 13)..Point::new(2, 21), // Third "variable" ]; let anchor_ranges: Vec<_> = highlight_ranges .iter() .map(|range| range.clone().to_anchors(&buffer_snapshot)) .collect(); editor.highlight_background( HighlightKey::DocumentHighlightRead, &anchor_ranges, |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); // Go to next highlight - should move to second "variable" cx.update_editor(|editor, window, cx| { editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); }); cx.assert_editor_state( "let variable = 42; let another = ˇvariable + 1; let result = variable * 2;", ); // Go to next highlight - should move to third "variable" cx.update_editor(|editor, window, cx| { editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); }); cx.assert_editor_state( "let variable = 42; let another = variable + 1; let result = ˇvariable * 2;", ); // Go to next highlight - should stay at third "variable" (no wrap-around) cx.update_editor(|editor, window, cx| { editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); }); cx.assert_editor_state( "let variable = 42; let another = variable + 1; let result = ˇvariable * 2;", ); // Now test going backwards from third position cx.update_editor(|editor, window, cx| { editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); cx.assert_editor_state( "let variable = 42; let another = ˇvariable + 1; let result = variable * 2;", ); // Go to previous highlight - should move to first "variable" cx.update_editor(|editor, window, cx| { editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); cx.assert_editor_state( "let ˇvariable = 42; let another = variable + 1; let result = variable * 2;", ); // Go to previous highlight - should stay on first "variable" cx.update_editor(|editor, window, cx| { editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); cx.assert_editor_state( "let ˇvariable = 42; let another = variable + 1; let result = variable * 2;", ); } #[gpui::test] async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let url = "https://zed.dev"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!( "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)" )); } #[gpui::test] async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Test if adding a character with multi cursors preserves nested list indents cx.set_state(&indoc! {" - [ ] Item 1 - [ ] Item 1.a - [ˇ] Item 2 - [ˇ] Item 2.a - [ˇ] Item 2.b " }); cx.update_editor(|editor, window, cx| { editor.handle_input("x", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a - [xˇ] Item 2 - [xˇ] Item 2.a - [xˇ] Item 2.b " }); // Case 2: Test adding new line after nested list continues the list with unchecked task cx.set_state(&indoc! {" - [ ] Item 1 - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - [x] Item 2.bˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - [ ] ˇ" }); // Case 3: Test adding content to continued list item cx.update_editor(|editor, window, cx| { editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - [ ] Item 2.cˇ" }); // Case 4: Test adding new line after nested ordered list continues with next number cx.set_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.bˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.assert_editor_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.b 3. ˇ" }); // Case 5: Adding content to continued ordered list item cx.update_editor(|editor, window, cx| { editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.b 3. Item 2.cˇ" }); // Case 6: Test adding new line after nested ordered list preserves indent of previous line cx.set_state(indoc! {" - Item 1 - Item 1.a - Item 1.a ˇ"}); cx.update_editor(|editor, window, cx| { editor.handle_input("-", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" - Item 1 - Item 1.a - Item 1.a -ˇ"}); // Case 7: Test blockquote newline preserves something cx.set_state(indoc! {" > Item 1ˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.assert_editor_state(indoc! {" > Item 1 ˇ" }); } #[gpui::test] async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let url = "https://zed.dev"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state(&format!( "Hello, editor.\nZed is great (see this link: )\n«{url}ˇ»" )); cx.update_editor(|editor, window, cx| { editor.copy(&Copy, window, cx); }); cx.set_state(&format!( "Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)\n{url}" )); cx.update_editor(|editor, window, cx| { editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!( "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)\n{url}" )); } #[gpui::test] async fn test_paste_url_from_other_app_replaces_existing_url_without_creating_markdown_link( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let url = "https://zed.dev"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state("Please visit zed's homepage: «https://www.apple.comˇ»"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!("Please visit zed's homepage: {url}ˇ")); } #[gpui::test] async fn test_paste_plain_text_from_other_app_replaces_selection_without_creating_markdown_link( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let text = "Awesome"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat»"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); } #[gpui::test] async fn test_paste_text_with_scheme_like_prefix_replaces_selection_without_creating_markdown_link( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); // `url::Url::parse` accepts this as a URL with the scheme `editor`, but it // should not be treated as one when pasting. let text = "editor: Fix double-click bracket selection for large spans"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state("«(feat on git-ui-add-info-exclude-to-context-menus) Fmtˇ»"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!("{text}ˇ")); } #[gpui::test] async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let url = "https://zed.dev"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Rust".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); cx.set_state("// Hello, «editorˇ».\n// Zed is «ˇgreat» (see this link: ˇ)"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!( "// Hello, {url}ˇ.\n// Zed is {url}ˇ (see this link: {url}ˇ)" )); } #[gpui::test] async fn test_paste_url_from_other_app_creates_markdown_link_selectively_in_multi_buffer( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let url = "https://zed.dev"; let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let (editor, cx) = cx.add_window_view(|window, cx| { let multi_buffer = MultiBuffer::build_multi( [ ("this will embed -> link", vec![Point::row_range(0..1)]), ("this will replace -> link", vec![Point::row_range(0..1)]), ], cx, ); let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(vec![ Point::new(0, 19)..Point::new(0, 23), Point::new(1, 21)..Point::new(1, 25), ]) }); let snapshot = multi_buffer.read(cx).snapshot(cx); let first_buffer_id = snapshot.all_buffer_ids().next().unwrap(); let first_buffer = multi_buffer.read(cx).buffer(first_buffer_id).unwrap(); first_buffer.update(cx, |buffer, cx| { buffer.set_language(Some(markdown_language.clone()), cx); }); editor }); let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); editor.paste(&Paste, window, cx); }); cx.assert_editor_state(&format!( "this will embed -> [link]({url})ˇ\nthis will replace -> {url}ˇ" )); } #[gpui::test] async fn test_paste_image_in_markdown_saves_file_and_inserts_markdown(cx: &mut TestAppContext) { init_test(cx, |_| {}); let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/test", serde_json::json!({"test.md": ""})) .await; let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/test/test.md", cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_language(Some(markdown_language), cx); }); let editor_window = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, MultiBuffer::build_from_buffer(buffer, cx), window, cx, ); window.focus(&editor.focus_handle(cx), cx); editor }); cx.run_until_parked(); let mut cx = EditorTestContext::for_editor(editor_window, cx).await; let png_bytes: Vec = vec![1, 2, 3, 4, 5]; let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes.clone()); cx.set_state("ˇ"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image)); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); let buffer_text = cx.buffer_text(); assert!( buffer_text.starts_with("![](image") && buffer_text.ends_with(".png)"), "expected markdown image syntax, got: {buffer_text:?}" ); // Cursor should land inside [] so the user can type alt text immediately. let filename_in_parens = &buffer_text["![](".len()..buffer_text.len() - 1]; let expected_state = format!("![ˇ]({filename_in_parens})"); cx.assert_editor_state(&expected_state); let filename = &buffer_text["![](".len()..buffer_text.len() - 1]; assert_eq!( fs.read_file_sync(format!("/test/{filename}")).unwrap(), png_bytes, "image file contents should match clipboard bytes" ); } #[gpui::test] async fn test_paste_multiple_images_in_markdown_increments_filename(cx: &mut TestAppContext) { init_test(cx, |_| {}); let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/test", serde_json::json!({"test.md": ""})) .await; let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/test/test.md", cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_language(Some(markdown_language), cx); }); let editor_window = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, MultiBuffer::build_from_buffer(buffer, cx), window, cx, ); window.focus(&editor.focus_handle(cx), cx); editor }); cx.run_until_parked(); let mut cx = EditorTestContext::for_editor(editor_window, cx).await; let png_bytes_1: Vec = vec![1, 2, 3, 4, 5]; let png_bytes_2: Vec = vec![6, 7, 8, 9, 10]; let png_bytes_3: Vec = vec![11, 12, 13, 14, 15]; let image_1 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_1.clone()); let image_2 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_2.clone()); let image_3 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_3.clone()); // Paste first image — should produce image.png cx.set_state("ˇ"); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image_1)); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); let text_after_first = cx.buffer_text(); assert_eq!( text_after_first, "![](image.png)", "first paste should produce image.png" ); assert_eq!(fs.read_file_sync("/test/image.png").unwrap(), png_bytes_1); // Paste second image at end — should produce image_1.png cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image_2)); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); let text_after_second = cx.buffer_text(); assert!( text_after_second.contains("![](image_1.png)"), "second paste should produce image_1.png, got: {text_after_second:?}" ); assert_eq!(fs.read_file_sync("/test/image_1.png").unwrap(), png_bytes_2); // Paste third image — should produce image_2.png cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image_3)); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); let text_after_third = cx.buffer_text(); assert!( text_after_third.contains("![](image_2.png)"), "third paste should produce image_2.png, got: {text_after_third:?}" ); assert_eq!(fs.read_file_sync("/test/image_2.png").unwrap(), png_bytes_3); } #[gpui::test] async fn test_paste_image_in_non_markdown_does_not_insert_markdown(cx: &mut TestAppContext) { init_test(cx, |_| {}); let rust_language = Arc::new(Language::new( LanguageConfig { name: "Rust".into(), ..LanguageConfig::default() }, None, )); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_language), cx)); cx.set_state("ˇ"); let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image)); editor.paste(&Paste, window, cx); }); cx.assert_editor_state("ˇ"); } #[gpui::test] async fn test_paste_image_in_markdown_without_open_file_falls_through(cx: &mut TestAppContext) { init_test(cx, |_| {}); let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); // Build an editor whose buffer has no associated file path on disk. let (editor, cx) = cx.add_window_view(|window, cx| { let buffer = cx.new(|cx| language::Buffer::local("", cx)); buffer.update(cx, |buffer, cx| { buffer.set_language(Some(markdown_language), cx); }); let multibuffer = MultiBuffer::build_from_buffer(buffer, cx); build_editor(multibuffer, window, cx) }); let mut cx = EditorTestContext::for_editor_in(editor, cx).await; cx.set_state("ˇ"); let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image)); editor.paste(&Paste, window, cx); }); cx.assert_editor_state("ˇ"); } #[gpui::test] async fn test_paste_image_in_markdown_single_file_worktree_falls_through(cx: &mut TestAppContext) { init_test(cx, |_| {}); let markdown_language = Arc::new(Language::new( LanguageConfig { name: "Markdown".into(), ..LanguageConfig::default() }, None, )); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/root", serde_json::json!({"test.md": ""})) .await; let project = Project::test(fs.clone(), [std::path::Path::new("/root/test.md")], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/root/test.md", cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_language(Some(markdown_language), cx); }); let editor_window = cx.add_window(|window, cx| { let editor = build_editor_with_project( project, MultiBuffer::build_from_buffer(buffer, cx), window, cx, ); window.focus(&editor.focus_handle(cx), cx); editor }); cx.run_until_parked(); let mut cx = EditorTestContext::for_editor(editor_window, cx).await; cx.set_state("ˇ"); let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); cx.update_editor(|editor, window, cx| { cx.write_to_clipboard(ClipboardItem::new_image(&image)); editor.paste(&Paste, window, cx); }); cx.run_until_parked(); cx.assert_editor_state("ˇ"); assert!( fs.read_file_sync("/root/image.png").is_err(), "no image file should be created for a single-file worktree" ); } #[gpui::test] async fn test_format_echoing_received_line_endings_keeps_cursor(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.ensure_final_newline_on_save = Some(false); }); let lf_content = "// one\nstruct Foo {\n bar: Bar,\n}\n\n// two\nstruct Bar {\n foobar:u32,\n}\n\nfn main() {\n let foo = 1;\n}\n"; let crlf_content = lf_content.replace('\n', "\r\n"); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), crlf_content.clone().into()) .await; let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let server_side_texts = Arc::new(Mutex::new(Vec::::new())); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, initializer: Some({ let server_side_texts = server_side_texts.clone(); Box::new(move |fake_server| { fake_server.handle_notification::({ let server_side_texts = server_side_texts.clone(); move |params, _| { server_side_texts.lock().push(params.text_document.text); } }); }) }), ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), multi_buffer, window, cx) }); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_ranges([Point::new(2, 4)..Point::new(2, 4)]); }); }); let server_side_text = server_side_texts.lock().last().cloned().unwrap(); assert_eq!( server_side_text, crlf_content, "server should have received the CRLF text on DidOpenTextDocument", ); let formatted_text = server_side_text.replace("foobar:u32", "foobar: u32"); fake_server.set_request_handler::(move |_, _| { let formatted_text = formatted_text.clone(); async move { Ok(Some(vec![lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(13, 0)), new_text: formatted_text, }])) } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, force_format: false, autosave: false, }, project.clone(), window, cx, ) }) .unwrap(); save.await; assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), lf_content.replace("foobar:u32", "foobar: u32"), "only the minimal formatting change should be applied", ); editor.update(cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); let cursor = editor.selections.newest::(&snapshot).head(); assert_eq!( cursor, Point::new(2, 4), "whole-document echo of the received line endings must not move the cursor", ); }); } #[gpui::test] async fn test_race_in_multibuffer_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "first.rs": "# First Document\nSome content here.", "second.rs": "Plain text content for second file.", }), ) .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let language = rust_lang(); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(language.clone()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { ..FakeLspAdapter::default() }, ); let buffer1 = project .update(cx, |project, cx| { project.open_local_buffer(PathBuf::from(path!("/project/first.rs")), cx) }) .await .unwrap(); let buffer2 = project .update(cx, |project, cx| { project.open_local_buffer(PathBuf::from(path!("/project/second.rs")), cx) }) .await .unwrap(); let multi_buffer = cx.new(|cx| { let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); multi_buffer.set_excerpts_for_path( PathKey::for_buffer(&buffer1, cx), buffer1.clone(), [Point::zero()..buffer1.read(cx).max_point()], 3, cx, ); multi_buffer.set_excerpts_for_path( PathKey::for_buffer(&buffer2, cx), buffer2.clone(), [Point::zero()..buffer1.read(cx).max_point()], 3, cx, ); multi_buffer }); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), multi_buffer, Some(project.clone()), window, cx, ) }); let fake_language_server = fake_servers.next().await.unwrap(); buffer1.update(cx, |buffer, cx| buffer.edit([(0..0, "hello!")], None, cx)); let save = editor.update_in(cx, |editor, window, cx| { assert!(editor.is_dirty(cx)); editor.save( SaveOptions { format: true, force_format: false, autosave: true, }, project, window, cx, ) }); let (start_edit_tx, start_edit_rx) = oneshot::channel(); let (done_edit_tx, done_edit_rx) = oneshot::channel(); let mut done_edit_rx = Some(done_edit_rx); let mut start_edit_tx = Some(start_edit_tx); fake_language_server.set_request_handler::(move |_, _| { start_edit_tx.take().unwrap().send(()).unwrap(); let done_edit_rx = done_edit_rx.take().unwrap(); async move { done_edit_rx.await.unwrap(); Ok(None) } }); start_edit_rx.await.unwrap(); buffer2 .update(cx, |buffer, cx| buffer.edit([(0..0, "world!")], None, cx)) .unwrap(); done_edit_tx.send(()).unwrap(); save.await.unwrap(); cx.update(|_, cx| assert!(editor.is_dirty(cx))); } #[gpui::test] fn test_duplicate_line_up_on_last_line_without_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("line1\nline2", cx); build_editor(buffer, window, cx) }); editor .update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) ]) }); editor.duplicate_line_up(&DuplicateLineUp, window, cx); assert_eq!( editor.display_text(cx), "line1\nline2\nline2", "Duplicating last line upward should create duplicate above, not on same line" ); assert_eq!( editor .selections .display_ranges(&editor.display_snapshot(cx)), vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)], "Selection should move to the duplicated line" ); }) .unwrap(); } #[gpui::test] async fn test_copy_line_without_trailing_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("line1\nline2ˇ"); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); let clipboard_text = cx .read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)); assert_eq!( clipboard_text, Some("line2\n".to_string()), "Copying a line without trailing newline should include a newline" ); cx.set_state("line1\nˇ"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("line1\nline2\nˇ"); } #[gpui::test] async fn test_multi_selection_copy_with_newline_between_copied_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇline1\nˇline2\nˇline3\n"); cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); let clipboard_text = cx .read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)); assert_eq!( clipboard_text, Some("line1\nline2\nline3\n".to_string()), "Copying multiple lines should include a single newline between lines" ); cx.set_state("lineA\nˇ"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); } #[gpui::test] async fn test_multi_selection_cut_with_newline_between_copied_lines(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇline1\nˇline2\nˇline3\n"); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); let clipboard_text = cx .read_from_clipboard() .and_then(|item| item.text().as_deref().map(str::to_string)); assert_eq!( clipboard_text, Some("line1\nline2\nline3\n".to_string()), "Copying multiple lines should include a single newline between lines" ); cx.set_state("lineA\nˇ"); cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); } #[gpui::test] async fn test_end_of_editor_context(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("line1\nline2ˇ"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::SingleLine); assert!(!e.key_context(window, cx).contains("start_of_input")); assert!(e.key_context(window, cx).contains("end_of_input")); }); cx.set_state("ˇline1\nline2"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::SingleLine); assert!(e.key_context(window, cx).contains("start_of_input")); assert!(!e.key_context(window, cx).contains("end_of_input")); }); cx.set_state("line1ˇ\nline2"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::SingleLine); assert!(!e.key_context(window, cx).contains("start_of_input")); assert!(!e.key_context(window, cx).contains("end_of_input")); }); cx.set_state("line1\nline2ˇ"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::AutoHeight { min_lines: 1, max_lines: Some(4), }); assert!(!e.key_context(window, cx).contains("start_of_input")); assert!(e.key_context(window, cx).contains("end_of_input")); }); cx.set_state("ˇline1\nline2"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::AutoHeight { min_lines: 1, max_lines: Some(4), }); assert!(e.key_context(window, cx).contains("start_of_input")); assert!(!e.key_context(window, cx).contains("end_of_input")); }); cx.set_state("line1ˇ\nline2"); cx.update_editor(|e, window, cx| { e.set_mode(EditorMode::AutoHeight { min_lines: 1, max_lines: Some(4), }); assert!(!e.key_context(window, cx).contains("start_of_input")); assert!(!e.key_context(window, cx).contains("end_of_input")); }); } #[gpui::test] async fn test_sticky_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let buffer = indoc! {" ˇfn foo() { let abc = 123; } struct Bar; impl Bar { fn new() -> Self { Self } } fn baz() { } "}; cx.set_state(&buffer); cx.update_editor(|e, _, cx| { e.buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }) }); let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, window, cx); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) .into_iter() .map( |StickyHeader { start_point, offset, .. }| { (start_point, offset) }, ) .collect::>() }) }; let fn_foo = Point { row: 0, column: 0 }; let impl_bar = Point { row: 4, column: 0 }; let fn_new = Point { row: 5, column: 4 }; assert_eq!(sticky_headers(0.0), vec![]); assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(1.5), vec![(fn_foo, -0.5)]); assert_eq!(sticky_headers(2.0), vec![]); assert_eq!(sticky_headers(2.5), vec![]); assert_eq!(sticky_headers(3.0), vec![]); assert_eq!(sticky_headers(3.5), vec![]); assert_eq!(sticky_headers(4.0), vec![]); assert_eq!(sticky_headers(4.5), vec![(impl_bar, 0.0), (fn_new, 1.0)]); assert_eq!(sticky_headers(5.0), vec![(impl_bar, 0.0), (fn_new, 1.0)]); assert_eq!(sticky_headers(5.5), vec![(impl_bar, 0.0), (fn_new, 0.5)]); assert_eq!(sticky_headers(6.0), vec![(impl_bar, 0.0)]); assert_eq!(sticky_headers(6.5), vec![(impl_bar, 0.0)]); assert_eq!(sticky_headers(7.0), vec![(impl_bar, 0.0)]); assert_eq!(sticky_headers(7.5), vec![(impl_bar, -0.5)]); assert_eq!(sticky_headers(8.0), vec![]); assert_eq!(sticky_headers(8.5), vec![]); assert_eq!(sticky_headers(9.0), vec![]); assert_eq!(sticky_headers(9.5), vec![]); assert_eq!(sticky_headers(10.0), vec![]); } #[gpui::test] async fn test_sticky_scroll_with_decoration_prefix_in_item(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let language = Arc::new( Language::new( LanguageConfig { name: "TypeScript".into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ) .with_outline_query( r#" (class_declaration "class" @context name: (_) @name) @item "#, ) .expect("TypeScript outline query"), ); let buffer = indoc! {" ˇ@Decorator class Foo { x = 1; y = 2; z = 3; w = 4; } "}; cx.set_state(buffer); cx.update_editor(|e, _, cx| { e.buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(language), cx); }) }); let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, window, cx); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) .into_iter() .map( |StickyHeader { start_point, offset, .. }| { (start_point, offset) }, ) .collect::>() }) }; let class_foo = Point { row: 1, column: 0 }; assert_eq!(sticky_headers(0.0), vec![]); assert_eq!(sticky_headers(1.5), vec![(class_foo, 0.0)]); assert_eq!(sticky_headers(2.5), vec![(class_foo, 0.0)]); assert_eq!(sticky_headers(5.5), vec![(class_foo, -0.5)]); assert_eq!(sticky_headers(6.0), vec![]); assert_eq!(sticky_headers(7.0), vec![]); } #[gpui::test] async fn test_sticky_scroll_anchors_multiline_c_signature_on_name_row(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let buffer = indoc! {" ˇvoid evdev_post_scroll(struct evdev_device *device, usec_t time, enum libinput_pointer_axis_source source, const struct normalized_coords *delta) { const struct normalized_coords tilt_rot = { cos(SCROLL_DELTA_TILT_ANGLE), sin(SCROLL_DELTA_TILT_ANGLE), }; } "}; cx.set_state(buffer); cx.update_editor(|editor, _, cx| { editor .buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language( Some(languages::language("c", tree_sitter_c::LANGUAGE.into())), cx, ); }) }); let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|editor, window, cx| { editor.scroll(gpui::Point { x: 0., y: offset }, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { EditorElement::sticky_headers(&editor, &editor.snapshot(window, cx)) .into_iter() .map( |StickyHeader { start_point, offset, .. }| { (start_point, offset) }, ) .collect::>() }) }; let function_name_row = Point { row: 1, column: 0 }; assert_eq!(sticky_headers(1.0), vec![]); assert_eq!(sticky_headers(1.5), vec![(function_name_row, 0.0)]); assert_eq!(sticky_headers(5.0), vec![(function_name_row, 0.0)]); } #[gpui::test] async fn test_sticky_scroll_with_expanded_deleted_diff_hunks( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = indoc! {" fn foo() { let a = 1; let b = 2; let c = 3; let d = 4; let e = 5; } "}; let buffer = indoc! {" ˇfn foo() { } "}; cx.set_state(&buffer); cx.update_editor(|e, _, cx| { e.buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }) }); cx.set_head_text(diff_base); executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); }); executor.run_until_parked(); // After expanding, the display should look like: // row 0: fn foo() { // row 1: - let a = 1; (deleted) // row 2: - let b = 2; (deleted) // row 3: - let c = 3; (deleted) // row 4: - let d = 4; (deleted) // row 5: - let e = 5; (deleted) // row 6: } // // fn foo() spans display rows 0-6. Scrolling into the deleted region // (rows 1-5) should still show fn foo() as a sticky header. let fn_foo = Point { row: 0, column: 0 }; let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, window, cx); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) .into_iter() .map( |StickyHeader { start_point, offset, .. }| { (start_point, offset) }, ) .collect::>() }) }; assert_eq!(sticky_headers(0.0), vec![]); assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); // Scrolling into deleted lines: fn foo() should still be a sticky header. assert_eq!(sticky_headers(2.0), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(3.0), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(4.0), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(5.0), vec![(fn_foo, 0.0)]); assert_eq!(sticky_headers(5.5), vec![(fn_foo, -0.5)]); // Past the closing brace: no more sticky header. assert_eq!(sticky_headers(6.0), vec![]); } #[gpui::test] async fn test_no_duplicated_sticky_headers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇimpl Foo { fn bar() { let x = 1; fn baz() { let y = 2; } } } "}); cx.update_editor(|e, _, cx| { e.buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }) }); let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, window, cx); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) .into_iter() .map( |StickyHeader { start_point, offset, .. }| { (start_point, offset) }, ) .collect::>() }) }; let struct_foo = Point { row: 0, column: 0 }; let fn_baz = Point { row: 2, column: 4 }; assert_eq!(sticky_headers(0.0), vec![]); assert_eq!(sticky_headers(0.5), vec![(struct_foo, 0.0)]); assert_eq!(sticky_headers(1.0), vec![(struct_foo, 0.0)]); assert_eq!(sticky_headers(1.5), vec![(struct_foo, 0.0), (fn_baz, 1.0)]); assert_eq!(sticky_headers(2.0), vec![(struct_foo, 0.0), (fn_baz, 1.0)]); assert_eq!(sticky_headers(2.5), vec![(struct_foo, 0.0), (fn_baz, 0.5)]); assert_eq!(sticky_headers(3.0), vec![(struct_foo, 0.0)]); assert_eq!(sticky_headers(3.5), vec![(struct_foo, 0.0)]); assert_eq!(sticky_headers(4.0), vec![(struct_foo, 0.0)]); assert_eq!(sticky_headers(4.5), vec![(struct_foo, -0.5)]); assert_eq!(sticky_headers(5.0), vec![]); } #[gpui::test] async fn test_autoscroll_margin_reserves_sticky_header_space(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.vertical_scroll_margin = Some(3.0); settings.sticky_scroll = Some(settings::StickyScrollContent { enabled: Some(true), }); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn demo() { let value_01 = 1; let value_02 = 2; let value_03 = 3; let value_04 = 4; let value_05 = 5; let value_06 = 6; let value_07 = 7; let value_08 = 8; let value_09 = 9; let value_10 = 10; ˇlet value_11 = 11; let value_12 = 12; let value_13 = 13; } "}); // Use a parsed scope so the test also exercises sticky-header discovery. cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.run_until_parked(); let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let window = cx.window; for visible_lines in [2., 2.5] { cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); // Center explicitly so the starting viewport does not depend on the // Fit calculation being tested. cx.update_editor(|editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(11, 4)..Point::new(11, 4)]); }); editor.scroll_cursor_center(&ScrollCursorCenter, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!(snapshot.scroll_position().y, 10.); assert_eq!(EditorElement::sticky_headers(editor, &snapshot).len(), 1); // Row 10 is covered by the header; moving there must scroll to expose it. editor.move_up(&MoveUp, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let cursor_row = editor .selections .newest_display(&snapshot.display_snapshot) .head() .row(); let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); assert_eq!(cursor_row, DisplayRow(10)); assert_eq!(snapshot.scroll_position().y, 9.); assert_eq!(sticky_header_count, 1); // A scroll alone is insufficient: the full cursor row must be unobscured. assert!( cursor_row.as_f64() >= snapshot.scroll_position().y + sticky_header_count as f64 ); assert!( cursor_row.next_row().as_f64() <= snapshot.scroll_position().y + visible_lines as f64 ); }); } } #[gpui::test] async fn test_autoscroll_keeps_cursor_visible_below_sticky_headers(cx: &mut TestAppContext) { init_test(cx, |_| {}); update_test_editor_settings(cx, &|settings| { settings.vertical_scroll_margin = Some(0.0); settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); settings.sticky_scroll = Some(settings::StickyScrollContent { enabled: Some(true), }); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" impl Foo { fn bar() { let x = 1; fn baz() { let y = 2; } } } ˇ "}); let mut previous_cursor_row = cx.update_editor(|editor, window, cx| { editor .buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); let cursor_row = editor .selections .newest_display(&editor.display_snapshot(cx)) .head() .row(); editor.set_scroll_top_row(cursor_row, window, cx); cursor_row }); for _ in 0..6 { cx.update_editor(|editor, window, cx| editor.move_up(&MoveUp, window, cx)); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let scroll_top = snapshot.scroll_position().y; let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); let cursor_row = editor .selections .newest_display(&snapshot.display_snapshot) .head() .row(); assert_eq!( cursor_row, previous_cursor_row .previous_row() .max(DisplayRow(scroll_top as u32) + DisplayRow(sticky_header_count as u32)) ); previous_cursor_row = cursor_row; }); // The `ScrollCursorTop` action shouldn't change the scroll position, as the cursor is // already as high up as the sticky headers allow. let scroll_top_before = cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); cx.update_editor(|editor, window, cx| { editor.scroll_cursor_top(&ScrollCursorTop, window, cx) }); cx.run_until_parked(); let scroll_top_after = cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); assert_eq!(scroll_top_before, scroll_top_after); } } #[gpui::test] fn test_relative_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx)); let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx)); let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx)); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.set_excerpts_for_path( PathKey::sorted(0), buffer_1.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(1), buffer_2.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multibuffer.set_excerpts_for_path( PathKey::sorted(2), buffer_3.clone(), [Point::new(0, 0)..Point::new(2, 0)], 0, cx, ); multibuffer }); // wrapped contents of multibuffer: // aaa // aaa // aaa // a // bbb // // ccc // ccc // ccc // c // ddd // // eee // fff // fff // fff // f let editor = cx.add_window(|window, cx| build_editor(multibuffer, window, cx)); _ = editor.update(cx, |editor, window, cx| { editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters // includes trailing newlines. let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23]; let expected_wrapped_line_numbers = [ 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, ]; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(7, 0)..Point::new(7, 1), // second row of `ccc` ]); }); let snapshot = editor.snapshot(window, cx); // these are all 0-indexed let base_display_row = DisplayRow(11); let base_row = 3; let wrapped_base_row = 7; // test not counting wrapped lines let expected_relative_numbers = expected_line_numbers .into_iter() .enumerate() .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32)) .filter(|(_, relative_line_number)| *relative_line_number != 0) .collect_vec(); let actual_relative_numbers = snapshot .calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(24)), base_display_row, false, ) .into_iter() .sorted() .collect_vec(); assert_eq!(expected_relative_numbers, actual_relative_numbers); // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line for (display_row, relative_number) in expected_relative_numbers { assert_eq!( relative_number, snapshot .relative_line_delta(display_row, base_display_row, false) .unsigned_abs() as u32, ); } // test counting wrapped lines let expected_wrapped_relative_numbers = expected_wrapped_line_numbers .into_iter() .enumerate() .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32)) .filter(|(row, _)| *row != base_display_row) .collect_vec(); let actual_relative_numbers = snapshot .calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(24)), base_display_row, true, ) .into_iter() .sorted() .collect_vec(); assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers); // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line for (display_row, relative_number) in expected_wrapped_relative_numbers { assert_eq!( relative_number, snapshot .relative_line_delta(display_row, base_display_row, true) .unsigned_abs() as u32, ); } }); } #[gpui::test] async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.editor.sticky_scroll = Some(settings::StickyScrollContent { enabled: Some(true), }) }); }); }); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let buffer = indoc! {" ˇfn foo() { let abc = 123; } struct Bar; impl Bar { fn new() -> Self { Self } } fn baz() { } "}; cx.set_state(&buffer); cx.update_editor(|e, _, cx| { e.buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }) }); let fn_foo = || empty_range(0, 0); let impl_bar = || empty_range(4, 0); let fn_new = || empty_range(5, 0); let mut scroll_and_click = |scroll_offset: ScrollOffset, click_offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll( gpui::Point { x: 0., y: scroll_offset, }, window, cx, ); }); cx.run_until_parked(); cx.simulate_click( gpui::Point { x: px(0.), y: click_offset as f32 * line_height, }, Modifiers::none(), ); cx.run_until_parked(); cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))) }; assert_eq!( scroll_and_click( 4.5, // impl Bar is halfway off the screen 0.0 // click top of screen ), // scrolled to impl Bar (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) ); assert_eq!( scroll_and_click( 4.5, // impl Bar is halfway off the screen 0.25 // click middle of impl Bar ), // scrolled to impl Bar (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) ); assert_eq!( scroll_and_click( 4.5, // impl Bar is halfway off the screen 1.5 // click below impl Bar (e.g. fn new()) ), // scrolled to fn new() - this is below the impl Bar header which has persisted (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) ); assert_eq!( scroll_and_click( 5.5, // fn new is halfway underneath impl Bar 0.75 // click on the overlap of impl Bar and fn new() ), (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) ); assert_eq!( scroll_and_click( 5.5, // fn new is halfway underneath impl Bar 1.25 // click on the visible part of fn new() ), (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) ); assert_eq!( scroll_and_click( 1.5, // fn foo is halfway off the screen 0.0 // click top of screen ), (gpui::Point { x: 0., y: 0. }, vec![fn_foo()]) ); assert_eq!( scroll_and_click( 1.5, // fn foo is halfway off the screen 0.75 // click visible part of let abc... ) .0, // no change in scroll // we don't assert on the visible_range because if we clicked the gutter, our line is fully selected (gpui::Point { x: 0., y: 1.5 }) ); // Verify clicking at a specific x position within a sticky header places // the cursor at the corresponding column. let (text_origin_x, em_width) = cx.update_editor(|editor, _, _| { let position_map = editor.last_position_map.as_ref().unwrap(); ( position_map.text_hitbox.bounds.origin.x, position_map.em_layout_width, ) }); // Click on "impl Bar {" sticky header at column 5 (the 'B' in 'Bar'). // The text "impl Bar {" starts at column 0, so column 5 = 'B'. let click_x = text_origin_x + em_width * 5.5; cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: 4.5 }, window, cx); }); cx.run_until_parked(); cx.simulate_click( gpui::Point { x: click_x, y: 0.25 * line_height, }, Modifiers::none(), ); cx.run_until_parked(); let (scroll_pos, selections) = cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))); assert_eq!(scroll_pos, gpui::Point { x: 0., y: 4. }); assert_eq!(selections, vec![empty_range(4, 5)]); } #[gpui::test] async fn test_clicking_sticky_header_sets_character_select_mode(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.editor.sticky_scroll = Some(settings::StickyScrollContent { enabled: Some(true), }) }); }); }); let mut cx = EditorTestContext::new(cx).await; let line_height = cx.update_editor(|editor, window, cx| { editor .style(cx) .text .line_height_in_pixels(window.rem_size()) }); let buffer = indoc! {" fn foo() { let abc = 123; } ˇstruct Bar; "}; cx.set_state(&buffer); cx.update_editor(|editor, _, cx| { editor .buffer() .read(cx) .as_singleton() .unwrap() .update(cx, |buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }) }); let text_origin_x = cx.update_editor(|editor, _, _| { editor .last_position_map .as_ref() .unwrap() .text_hitbox .bounds .origin .x }); cx.update_editor(|editor, window, cx| { // Double click on `struct` to select it editor.begin_selection(DisplayPoint::new(DisplayRow(3), 1), false, 2, window, cx); editor.end_selection(window, cx); // Scroll down one row to make `fn foo() {` a sticky header editor.scroll(gpui::Point { x: 0., y: 1. }, window, cx); }); cx.run_until_parked(); // Click at the start of the `fn foo() {` sticky header cx.simulate_click( gpui::Point { x: text_origin_x, y: 0.5 * line_height, }, Modifiers::none(), ); cx.run_until_parked(); // Shift-click at the end of `fn foo() {` to select the whole row cx.update_editor(|editor, window, cx| { editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); editor.end_selection(window, cx); }); cx.run_until_parked(); let selections = cx.update_editor(|editor, _, cx| display_ranges(editor, cx)); assert_eq!( selections, vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 10)] ); } #[gpui::test] async fn test_next_prev_reference(cx: &mut TestAppContext) { const CYCLE_POSITIONS: &[&'static str] = &[ indoc! {" fn foo() { let ˇabc = 123; let x = abc + 1; let y = abc + 2; let z = abc + 2; } "}, indoc! {" fn foo() { let abc = 123; let x = ˇabc + 1; let y = abc + 2; let z = abc + 2; } "}, indoc! {" fn foo() { let abc = 123; let x = abc + 1; let y = ˇabc + 2; let z = abc + 2; } "}, indoc! {" fn foo() { let abc = 123; let x = abc + 1; let y = abc + 2; let z = ˇabc + 2; } "}, ]; init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { references_provider: Some(lsp::OneOf::Left(true)), ..Default::default() }, cx, ) .await; // importantly, the cursor is in the middle cx.set_state(indoc! {" fn foo() { let aˇbc = 123; let x = abc + 1; let y = abc + 2; let z = abc + 2; } "}); let reference_ranges = [ lsp::Position::new(1, 8), lsp::Position::new(2, 12), lsp::Position::new(3, 12), lsp::Position::new(4, 12), ] .map(|start| lsp::Range::new(start, lsp::Position::new(start.line, start.character + 3))); cx.lsp .set_request_handler::(move |params, _cx| async move { Ok(Some( reference_ranges .map(|range| lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range, }) .to_vec(), )) }); let _move = async |direction, count, cx: &mut EditorLspTestContext| { cx.update_editor(|editor, window, cx| { editor.go_to_reference_before_or_after_position(direction, count, window, cx) }) .unwrap() .await .unwrap() }; _move(Direction::Next, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[1]); _move(Direction::Next, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[2]); _move(Direction::Next, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[3]); // loops back to the start _move(Direction::Next, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[0]); // loops back to the end _move(Direction::Prev, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[3]); _move(Direction::Prev, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[2]); _move(Direction::Prev, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[1]); _move(Direction::Prev, 1, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[0]); _move(Direction::Next, 3, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[3]); _move(Direction::Prev, 2, &mut cx).await; cx.assert_editor_state(CYCLE_POSITIONS[1]); } #[gpui::test] async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (editor, cx) = cx.add_window_view(|window, cx| { let multi_buffer = MultiBuffer::build_multi( [ ("1\n2\n3\n", vec![Point::row_range(0..3)]), ("1\n2\n3\n", vec![Point::row_range(0..3)]), ], cx, ); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; let buffer_ids = cx.multibuffer(|mb, cx| { mb.snapshot(cx) .excerpts() .map(|excerpt| excerpt.context.start.buffer_id) .collect::>() }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] ˇ1 2 3 [EXCERPT] 1 2 3 "}); // Scenario 1: Unfolded buffers, position cursor on "2", select all matches, then insert cx.update_editor(|editor, window, cx| { editor.change_selections(None.into(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); }); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2ˇ 3 [EXCERPT] 1 2 3 "}); cx.update_editor(|editor, window, cx| { editor .select_all_matches(&SelectAllMatches, window, cx) .unwrap(); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2ˇ 3 [EXCERPT] 1 2ˇ 3 "}); cx.update_editor(|editor, window, cx| { editor.handle_input("X", window, cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 Xˇ 3 [EXCERPT] 1 Xˇ 3 "}); // Scenario 2: Select "2", then fold second buffer before insertion cx.update_multibuffer(|mb, cx| { for buffer_id in buffer_ids.iter() { let buffer = mb.buffer(*buffer_id).unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); }); } }); // Select "2" and select all matches cx.update_editor(|editor, window, cx| { editor.change_selections(None.into(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); }); editor .select_all_matches(&SelectAllMatches, window, cx) .unwrap(); }); // Fold second buffer - should remove selections from folded buffer cx.update_editor(|editor, _, cx| { editor.fold_buffer(buffer_ids[1], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2ˇ 3 [EXCERPT] [FOLDED] "}); // Insert text - should only affect first buffer cx.update_editor(|editor, window, cx| { editor.handle_input("Y", window, cx); }); cx.update_editor(|editor, _, cx| { editor.unfold_buffer(buffer_ids[1], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 Yˇ 3 [EXCERPT] 1 2 3 "}); // Scenario 3: Select "2", then fold first buffer before insertion cx.update_multibuffer(|mb, cx| { for buffer_id in buffer_ids.iter() { let buffer = mb.buffer(*buffer_id).unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); }); } }); // Select "2" and select all matches cx.update_editor(|editor, window, cx| { editor.change_selections(None.into(), window, cx, |s| { s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); }); editor .select_all_matches(&SelectAllMatches, window, cx) .unwrap(); }); // Fold first buffer - should remove selections from folded buffer cx.update_editor(|editor, _, cx| { editor.fold_buffer(buffer_ids[0], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] 1 2ˇ 3 "}); // Insert text - should only affect second buffer cx.update_editor(|editor, window, cx| { editor.handle_input("Z", window, cx); }); cx.update_editor(|editor, _, cx| { editor.unfold_buffer(buffer_ids[0], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2 3 [EXCERPT] 1 Zˇ 3 "}); // Test correct folded header is selected upon fold cx.update_editor(|editor, _, cx| { editor.fold_buffer(buffer_ids[0], cx); editor.fold_buffer(buffer_ids[1], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] [FOLDED] [EXCERPT] ˇ[FOLDED] "}); // Test selection inside folded buffer unfolds it on type cx.update_editor(|editor, window, cx| { editor.handle_input("W", window, cx); }); cx.update_editor(|editor, _, cx| { editor.unfold_buffer(buffer_ids[0], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2 3 [EXCERPT] Wˇ1 Z 3 "}); } #[gpui::test] async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (editor, cx) = cx.add_window_view(|window, cx| { let multi_buffer = MultiBuffer::build_multi( [ ("1\n2\n3\n", vec![Point::row_range(0..3)]), ("1\n2\n3\n4\n5\n6\n7\n8\n9\n", vec![Point::row_range(0..9)]), ], cx, ); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] ˇ1 2 3 [EXCERPT] 1 2 3 4 5 6 7 8 9 "}); cx.update_editor(|editor, window, cx| { editor.change_selections(None.into(), window, cx, |s| { s.select_ranges([MultiBufferOffset(19)..MultiBufferOffset(19)]); }); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] 1 2 3 [EXCERPT] 1 2 3 4 5 6 ˇ7 8 9 "}); cx.update_editor(|editor, _window, cx| { editor.set_vertical_scroll_margin(0, cx); }); cx.update_editor(|editor, window, cx| { assert_eq!(editor.vertical_scroll_margin(), 0); editor.scroll_cursor_top(&ScrollCursorTop, window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 12.0) ); }); cx.update_editor(|editor, _window, cx| { editor.set_vertical_scroll_margin(3, cx); }); cx.update_editor(|editor, window, cx| { assert_eq!(editor.vertical_scroll_margin(), 3); editor.scroll_cursor_top(&ScrollCursorTop, window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 9.0) ); }); } #[gpui::test] async fn test_find_references_single_case(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { references_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; let before = indoc!( r#" fn main() { let aˇbc = 123; let xyz = abc; } "# ); let after = indoc!( r#" fn main() { let abc = 123; let xyz = ˇabc; } "# ); cx.lsp .set_request_handler::(async move |params, _| { Ok(Some(vec![ lsp::Location { uri: params.text_document_position.text_document.uri.clone(), range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), }, lsp::Location { uri: params.text_document_position.text_document.uri, range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), }, ])) }); cx.set_state(before); let action = FindAllReferences { always_open_multibuffer: false, open_results_in: None, }; let navigated = cx .update_editor(|editor, window, cx| editor.find_all_references(&action, window, cx)) .expect("should have spawned a task") .await .unwrap(); assert_eq!(navigated, Navigated::No); cx.run_until_parked(); cx.assert_editor_state(after); } /// Maps each resolved [`Location`] to `(row, start_column, end_column)`. fn location_row_columns( cx: &mut EditorLspTestContext, locations: &[project::Location], ) -> Vec<(u32, u32, u32)> { let mut rows = cx.update_editor(|_editor, _window, cx| { locations .iter() .map(|location| { let snapshot = location.buffer.read(cx).snapshot(); let start: usize = snapshot.summary_for_anchor(&location.range.start); let end: usize = snapshot.summary_for_anchor(&location.range.end); let start = snapshot.offset_to_point(start); let end = snapshot.offset_to_point(end); (start.row, start.column, end.column) }) .collect::>() }); rows.sort(); rows } #[gpui::test] async fn test_definition_locations_of_kind_excludes_self_link(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { definition_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, cx, ) .await; // Cursor sits inside the `abc` use on row 2 (columns 14..17). cx.set_state(indoc!( r#" fn main() { let abc = 123; let xyz = aˇbc; } "# )); // The server returns two targets: the location covering the cursor (which // must be filtered out as a self-link) and the real definition on row 1. cx.lsp .set_request_handler::(async move |params, _| { let uri = params.text_document_position_params.text_document.uri; Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ lsp::Location { uri: uri.clone(), range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), }, lsp::Location { uri, range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), }, ]))) }); let locations = cx .update_editor(|editor, _window, cx| { editor.definition_locations_of_kind(GotoDefinitionKind::Symbol, cx) }) .expect("definition query should spawn a task") .await .unwrap(); // Only the real definition remains; the target covering the cursor is dropped. assert_eq!(location_row_columns(&mut cx, &locations), vec![(1, 8, 11)]); } #[test] fn test_open_results_in_action_argument_parsing() { // A bare keybinding (no arguments) resolves to `None`, i.e. defer to the // `lsp_results_location` setting. Keymap loading builds actions from `{}` // when no arguments are given, so that is what we deserialize here. assert_eq!( serde_json::from_value::(json!({})) .unwrap() .open_results_in, None, ); // The `OpenResultsIn` variants must keep the snake_case spelling that // keymaps and the `lsp_results_location` setting rely on; a rename here // would silently break those keybindings. assert_eq!( serde_json::from_value::(json!({ "open_results_in": "picker" })) .unwrap() .open_results_in, Some(OpenResultsIn::Picker), ); assert_eq!( serde_json::from_value::(json!({ "open_results_in": "multi_buffer" })) .unwrap() .open_results_in, Some(OpenResultsIn::MultiBuffer), ); // The argument coexists with `FindAllReferences`'s existing field, which // keeps its own default when only `open_results_in` is provided. let references = serde_json::from_value::(json!({ "open_results_in": "picker" })) .unwrap(); assert_eq!(references.open_results_in, Some(OpenResultsIn::Picker)); assert!(references.always_open_multibuffer); } #[gpui::test] async fn test_newline_task_list_continuation(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = Some(2.try_into().unwrap()); }); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker cx.set_state(indoc! {" - [ ] taskˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ] task - [ ] ˇ "}); // Case 2: Works with checked task items too cx.set_state(indoc! {" - [x] completed taskˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [x] completed task - [ ] ˇ "}); // Case 2.1: Works with uppercase checked marker too cx.set_state(indoc! {" - [X] completed taskˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [X] completed task - [ ] ˇ "}); // Case 3: Cursor position doesn't matter - content after marker is what counts cx.set_state(indoc! {" - [ ] taˇsk "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ] ta - [ ] ˇsk "}); // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker cx.set_state(indoc! {" - [ ] ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state( indoc! {" - [ ]$$ ˇ "} .replace("$", " ") .as_str(), ); // Case 5: Adding newline with content adds marker preserving indentation cx.set_state(indoc! {" - [ ] task - [ ] indentedˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ] task - [ ] indented - [ ] ˇ "}); // Case 6: Adding newline with cursor right after prefix, unindents cx.set_state(indoc! {" - [ ] task - [ ] sub task - [ ] ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ] task - [ ] sub task - [ ] ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; // Case 7: Adding newline with cursor right after prefix, removes marker cx.assert_editor_state(indoc! {" - [ ] task - [ ] sub task - [ ] ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ] task - [ ] sub task ˇ "}); // Case 8: Cursor before or inside prefix does not add marker cx.set_state(indoc! {" ˇ- [ ] task "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" ˇ- [ ] task "}); cx.set_state(indoc! {" - [ˇ ] task "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - [ ˇ ] task "}); } #[gpui::test] async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = Some(2.try_into().unwrap()); }); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker cx.set_state(indoc! {" - itemˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - item - ˇ "}); // Case 2: Works with different markers cx.set_state(indoc! {" * starred itemˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" * starred item * ˇ "}); cx.set_state(indoc! {" + plus itemˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" + plus item + ˇ "}); // Case 3: Cursor position doesn't matter - content after marker is what counts cx.set_state(indoc! {" - itˇem "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - it - ˇem "}); // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker cx.set_state(indoc! {" - ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state( indoc! {" - $ ˇ "} .replace("$", " ") .as_str(), ); // Case 5: Adding newline with content adds marker preserving indentation cx.set_state(indoc! {" - item - indentedˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - item - indented - ˇ "}); // Case 6: Adding newline with cursor right after marker, unindents cx.set_state(indoc! {" - item - sub item - ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - item - sub item - ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; // Case 7: Adding newline with cursor right after marker, removes marker cx.assert_editor_state(indoc! {" - item - sub item - ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - item - sub item ˇ "}); // Case 8: Cursor before or inside prefix does not add marker cx.set_state(indoc! {" ˇ- item "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" ˇ- item "}); cx.set_state(indoc! {" -ˇ item "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - ˇitem "}); update_test_language_settings(&mut cx, &|settings| { settings.defaults.tab_size = Some(4.try_into().unwrap()); }); // Case 9: Empty list item unindent works when tab size is larger than list indentation cx.set_state(indoc! {" - item - sub item - ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" - item - sub item - ˇ "}); // Case 10: Empty list item unindent moves to the previous tab stop cx.set_state( indoc! {" $$$$$$- ˇ "} .replace("$", " ") .as_str(), ); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state( indoc! {" $$$$- ˇ "} .replace("$", " ") .as_str(), ); } #[gpui::test] async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = Some(2.try_into().unwrap()); }); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number cx.set_state(indoc! {" 1. first itemˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. first item 2. ˇ "}); // Case 2: Works with larger numbers cx.set_state(indoc! {" 10. tenth itemˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 10. tenth item 11. ˇ "}); // Case 3: Cursor position doesn't matter - content after marker is what counts cx.set_state(indoc! {" 1. itˇem "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. it 2. ˇem "}); // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker cx.set_state(indoc! {" 1. ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state( indoc! {" 1. $ ˇ "} .replace("$", " ") .as_str(), ); // Case 5: Adding newline with content adds marker preserving indentation cx.set_state(indoc! {" 1. item 2. indentedˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. item 2. indented 3. ˇ "}); // Case 6: Adding newline with cursor right after marker, unindents cx.set_state(indoc! {" 1. item 2. sub item 3. ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. item 2. sub item 1. ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; // Case 7: Adding newline with cursor right after marker, removes marker cx.assert_editor_state(indoc! {" 1. item 2. sub item 1. ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. item 2. sub item ˇ "}); // Case 8: Cursor before or inside prefix does not add marker cx.set_state(indoc! {" ˇ1. item "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" ˇ1. item "}); cx.set_state(indoc! {" 1ˇ. item "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1 ˇ. item "}); } #[gpui::test] async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = Some(2.try_into().unwrap()); }); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number cx.set_state(indoc! {" 1. first item 1. sub first item 2. sub second item 3. ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" 1. first item 1. sub first item 2. sub second item 1. ˇ "}); } #[gpui::test] async fn test_tab_list_indent(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.tab_size = Some(2.try_into().unwrap()); }); let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); // Case 1: Unordered list - cursor after prefix, adds indent before prefix cx.set_state(indoc! {" - ˇitem "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$- ˇitem "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 2: Task list - cursor after prefix cx.set_state(indoc! {" - [ ] ˇtask "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$- [ ] ˇtask "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 3: Ordered list - cursor after prefix cx.set_state(indoc! {" 1. ˇfirst "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$1. ˇfirst "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 4: With existing indentation - adds more indent let initial = indoc! {" $$- ˇitem "}; cx.set_state(initial.replace("$", " ").as_str()); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$$$- ˇitem "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 5: Empty list item cx.set_state(indoc! {" - ˇ "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$- ˇ "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 6: Cursor at end of line with content cx.set_state(indoc! {" - itemˇ "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" $$- itemˇ "}; cx.assert_editor_state(expected.replace("$", " ").as_str()); // Case 7: Cursor at start of list item, indents it cx.set_state(indoc! {" - item ˇ - sub item "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" - item ˇ - sub item "}; cx.assert_editor_state(expected); // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false cx.update_editor(|_, _, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.indent_list_on_tab = Some(false); }); }); }); cx.set_state(indoc! {" - item ˇ - sub item "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); cx.wait_for_autoindent_applied().await; let expected = indoc! {" - item ˇ- sub item "}; cx.assert_editor_state(expected); } #[gpui::test] async fn test_local_worktree_trust(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| project::trusted_worktrees::init(HashMap::default(), cx)); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.inlay_hints = Some(InlayHintSettingsContent { enabled: Some(true), ..InlayHintSettingsContent::default() }); }); }); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".zed": { "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"# }, "main.rs": "fn main() {}" }), ) .await; let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0)); let server_name = "override-rust-analyzer"; let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let capabilities = lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }; let mut fake_language_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: server_name, capabilities, initializer: Some(Box::new({ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); move |fake_server| { let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); fake_server.set_request_handler::( move |_params, _| { lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release); async move { Ok(Some(vec![lsp::InlayHint { position: lsp::Position::new(0, 0), label: lsp::InlayHintLabel::String("hint".to_string()), kind: None, text_edits: None, tooltip: None, padding_left: None, padding_right: None, data: None, }])) } }, ); } })), ..FakeLspAdapter::default() }, ); cx.run_until_parked(); let worktree_id = project.read_with(cx, |project, cx| { project .worktrees(cx) .next() .map(|wt| wt.read(cx).id()) .expect("should have a worktree") }); let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); let trusted_worktrees = cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist")); let can_trust = trusted_worktrees.update(cx, |store, cx| { store.can_trust(&worktree_store, worktree_id, cx) }); assert!(!can_trust, "worktree should be restricted initially"); let buffer_before_approval = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let (editor, cx) = cx.add_window_view(|window, cx| { Editor::new( EditorMode::full(), cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)), Some(project.clone()), window, cx, ) }); cx.run_until_parked(); let fake_language_server = fake_language_servers.next(); cx.read(|cx| { assert_eq!( language::language_settings::LanguageSettings::for_buffer( buffer_before_approval.read(cx), cx ) .language_servers, [language::language_settings::ConfiguredLanguageServer::new( "..." )], "local .zed/settings.json must not apply before trust approval" ) }); editor.update_in(cx, |editor, window, cx| { editor.handle_input("1", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(std::time::Duration::from_secs(1)); assert_eq!( lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire), 0, "inlay hints must not be queried before trust approval" ); trusted_worktrees.update(cx, |store, cx| { store.trust( &worktree_store, std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]), cx, ); }); cx.run_until_parked(); cx.read(|cx| { assert_eq!( language::language_settings::LanguageSettings::for_buffer( buffer_before_approval.read(cx), cx ) .language_servers, [language::language_settings::ConfiguredLanguageServer::new( "override-rust-analyzer" )], "local .zed/settings.json should apply after trust approval" ) }); let _fake_language_server = fake_language_server.await.unwrap(); editor.update_in(cx, |editor, window, cx| { editor.handle_input("1", window, cx); }); cx.run_until_parked(); cx.executor() .advance_clock(std::time::Duration::from_secs(1)); assert!( lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0, "inlay hints should be queried after trust approval" ); let can_trust_after = trusted_worktrees.update(cx, |store, cx| { store.can_trust(&worktree_store, worktree_id, cx) }); assert!(can_trust_after, "worktree should be trusted after trust()"); } #[gpui::test] fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) { // This test reproduces a bug where drawing an editor at a position above the viewport // (simulating what happens when an AutoHeight editor inside a List is scrolled past) // causes an infinite loop in blocks_in_range. // // The issue: when the editor's bounds.origin.y is very negative (above the viewport), // the content mask intersection produces visible_bounds with origin at the viewport top. // This makes clipped_top_in_lines very large, causing start_row to exceed max_row. // When blocks_in_range is called with start_row > max_row, the cursor seeks to the end // but the while loop after seek never terminates because cursor.next() is a no-op at end. init_test(cx, |_| {}); let window = cx.add_window(|_, _| gpui::Empty); let mut cx = VisualTestContext::from_window(*window, cx); let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx)); let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx)); // Simulate a small viewport (500x500 pixels at origin 0,0) cx.simulate_resize(gpui::size(px(500.), px(500.))); // Draw the editor at a very negative Y position, simulating an editor that's been // scrolled way above the visible viewport (like in a List that has scrolled past it). // The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport. // This should NOT hang - it should just render nothing. cx.draw( gpui::point(px(0.), px(-10000.)), gpui::size(px(500.), px(3000.)), |_, _| editor.clone().into_any_element(), ); // If we get here without hanging, the test passes } #[gpui::test] async fn test_diff_review_indicator_created_on_gutter_hover(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); // Enable diff review button mode editor.update(cx, |editor, cx| { editor.set_show_diff_review_button(true, cx); }); // Initially, no indicator should be present editor.update(cx, |editor, _cx| { assert!( editor.gutter_diff_review_indicator.0.is_none(), "Indicator should be None initially" ); }); } #[gpui::test] async fn test_diff_review_button_hidden_when_ai_disabled(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Register DisableAiSettings and set disable_ai to true cx.update(|cx| { project::DisableAiSettings::register(cx); project::DisableAiSettings::override_global( project::DisableAiSettings { disable_ai: true }, cx, ); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); // Enable diff review button mode editor.update(cx, |editor, cx| { editor.set_show_diff_review_button(true, cx); }); // Verify AI is disabled cx.read(|cx| { assert!( project::DisableAiSettings::get_global(cx).disable_ai, "AI should be disabled" ); }); // The indicator should not be created when AI is disabled // (The mouse_moved handler checks DisableAiSettings before creating the indicator) editor.update(cx, |editor, _cx| { assert!( editor.gutter_diff_review_indicator.0.is_none(), "Indicator should be None when AI is disabled" ); }); } #[gpui::test] async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Register DisableAiSettings and set disable_ai to false cx.update(|cx| { project::DisableAiSettings::register(cx); project::DisableAiSettings::override_global( project::DisableAiSettings { disable_ai: false }, cx, ); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), window, cx, ) }) .await .unwrap() .downcast::() .unwrap(); // Enable diff review button mode editor.update(cx, |editor, cx| { editor.set_show_diff_review_button(true, cx); }); // Verify AI is enabled cx.read(|cx| { assert!( !project::DisableAiSettings::get_global(cx).disable_ai, "AI should be enabled" ); }); // The show_diff_review_button flag should be true editor.update(cx, |editor, _cx| { assert!( editor.show_diff_review_button(), "show_diff_review_button should be true" ); }); } /// Helper function to create a DiffHunkKey for testing. /// Uses Anchor::Min as a placeholder anchor since these tests don't need /// real buffer positioning. fn test_hunk_key(file_path: &str) -> DiffHunkKey { DiffHunkKey { file_path: if file_path.is_empty() { Arc::from(util::rel_path::RelPath::empty()) } else { Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) }, hunk_start_anchor: Anchor::Min, } } /// Helper function to create a DiffHunkKey with a specific anchor for testing. fn test_hunk_key_with_anchor(file_path: &str, anchor: Anchor) -> DiffHunkKey { DiffHunkKey { file_path: if file_path.is_empty() { Arc::from(util::rel_path::RelPath::empty()) } else { Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) }, hunk_start_anchor: anchor, } } /// Helper function to add a review comment with default anchors for testing. fn add_test_comment( editor: &mut Editor, key: DiffHunkKey, comment: &str, cx: &mut Context, ) -> usize { editor.add_review_comment(key, comment.to_string(), Anchor::Min..Anchor::Max, cx) } #[gpui::test] fn test_review_comment_add_to_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor: &mut Editor, _window, cx| { let key = test_hunk_key(""); let id = add_test_comment(editor, key.clone(), "Test comment", cx); let snapshot = editor.buffer().read(cx).snapshot(cx); assert_eq!(editor.total_review_comment_count(), 1); assert_eq!(editor.hunk_comment_count(&key, &snapshot), 1); let comments = editor.comments_for_hunk(&key, &snapshot); assert_eq!(comments.len(), 1); assert_eq!(comments[0].comment, "Test comment"); assert_eq!(comments[0].id, id); }); } #[gpui::test] fn test_review_comments_are_per_hunk(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor: &mut Editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor1 = snapshot.anchor_before(Point::new(0, 0)); let anchor2 = snapshot.anchor_before(Point::new(0, 0)); let key1 = test_hunk_key_with_anchor("file1.rs", anchor1); let key2 = test_hunk_key_with_anchor("file2.rs", anchor2); add_test_comment(editor, key1.clone(), "Comment for file1", cx); add_test_comment(editor, key2.clone(), "Comment for file2", cx); let snapshot = editor.buffer().read(cx).snapshot(cx); assert_eq!(editor.total_review_comment_count(), 2); assert_eq!(editor.hunk_comment_count(&key1, &snapshot), 1); assert_eq!(editor.hunk_comment_count(&key2, &snapshot), 1); assert_eq!( editor.comments_for_hunk(&key1, &snapshot)[0].comment, "Comment for file1" ); assert_eq!( editor.comments_for_hunk(&key2, &snapshot)[0].comment, "Comment for file2" ); }); } #[gpui::test] fn test_review_comment_remove(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor: &mut Editor, _window, cx| { let key = test_hunk_key(""); let id = add_test_comment(editor, key, "To be removed", cx); assert_eq!(editor.total_review_comment_count(), 1); let removed = editor.remove_review_comment(id, cx); assert!(removed); assert_eq!(editor.total_review_comment_count(), 0); // Try to remove again let removed_again = editor.remove_review_comment(id, cx); assert!(!removed_again); }); } #[gpui::test] fn test_review_comment_update(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor: &mut Editor, _window, cx| { let key = test_hunk_key(""); let id = add_test_comment(editor, key.clone(), "Original text", cx); let updated = editor.update_review_comment(id, "Updated text".to_string(), cx); assert!(updated); let snapshot = editor.buffer().read(cx).snapshot(cx); let comments = editor.comments_for_hunk(&key, &snapshot); assert_eq!(comments[0].comment, "Updated text"); assert!(!comments[0].is_editing); // Should clear editing flag }); } #[gpui::test] fn test_review_comment_take_all(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor: &mut Editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor1 = snapshot.anchor_before(Point::new(0, 0)); let anchor2 = snapshot.anchor_before(Point::new(0, 0)); let key1 = test_hunk_key_with_anchor("file1.rs", anchor1); let key2 = test_hunk_key_with_anchor("file2.rs", anchor2); let id1 = add_test_comment(editor, key1.clone(), "Comment 1", cx); let id2 = add_test_comment(editor, key1.clone(), "Comment 2", cx); let id3 = add_test_comment(editor, key2.clone(), "Comment 3", cx); // IDs should be sequential starting from 0 assert_eq!(id1, 0); assert_eq!(id2, 1); assert_eq!(id3, 2); assert_eq!(editor.total_review_comment_count(), 3); let taken = editor.take_all_review_comments(cx); // Should have 2 entries (one per hunk) assert_eq!(taken.len(), 2); // Total comments should be 3 let total: usize = taken .iter() .map(|(_, comments): &(DiffHunkKey, Vec)| comments.len()) .sum(); assert_eq!(total, 3); // Storage should be empty assert_eq!(editor.total_review_comment_count(), 0); // After taking all comments, ID counter should reset // New comments should get IDs starting from 0 again let new_id1 = add_test_comment(editor, key1, "New Comment 1", cx); let new_id2 = add_test_comment(editor, key2, "New Comment 2", cx); assert_eq!(new_id1, 0, "ID counter should reset after take_all"); assert_eq!(new_id2, 1, "IDs should be sequential after reset"); }); } #[gpui::test] fn test_diff_review_overlay_show_and_dismiss(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Show overlay editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); }) .unwrap(); // Verify overlay is shown editor .update(cx, |editor, _window, cx| { assert!(!editor.diff_review_overlays.is_empty()); assert_eq!(editor.diff_review_line_range(cx), Some((0, 0))); assert!(editor.diff_review_prompt_editor().is_some()); }) .unwrap(); // Dismiss overlay editor .update(cx, |editor, _window, cx| { editor.dismiss_all_diff_review_overlays(cx); }) .unwrap(); // Verify overlay is dismissed editor .update(cx, |editor, _window, cx| { assert!(editor.diff_review_overlays.is_empty()); assert_eq!(editor.diff_review_line_range(cx), None); assert!(editor.diff_review_prompt_editor().is_none()); }) .unwrap(); } #[gpui::test] fn test_diff_review_overlay_dismiss_via_cancel(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Show overlay editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); }) .unwrap(); // Verify overlay is shown editor .update(cx, |editor, _window, _cx| { assert!(!editor.diff_review_overlays.is_empty()); }) .unwrap(); // Dismiss via dismiss_menus_and_popups (which is called by cancel action) editor .update(cx, |editor, window, cx| { editor.dismiss_menus_and_popups(true, window, cx); }) .unwrap(); // Verify overlay is dismissed editor .update(cx, |editor, _window, _cx| { assert!(editor.diff_review_overlays.is_empty()); }) .unwrap(); } #[gpui::test] fn test_diff_review_empty_comment_not_submitted(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Show overlay editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); }) .unwrap(); // Try to submit without typing anything (empty comment) editor .update(cx, |editor, window, cx| { editor.submit_diff_review_comment(window, cx); }) .unwrap(); // Verify no comment was added editor .update(cx, |editor, _window, _cx| { assert_eq!(editor.total_review_comment_count(), 0); }) .unwrap(); // Try to submit with whitespace-only comment editor .update(cx, |editor, window, cx| { if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() { prompt_editor.update(cx, |pe, cx| { pe.insert(" \n\t ", window, cx); }); } editor.submit_diff_review_comment(window, cx); }) .unwrap(); // Verify still no comment was added editor .update(cx, |editor, _window, _cx| { assert_eq!(editor.total_review_comment_count(), 0); }) .unwrap(); } #[gpui::test] fn test_diff_review_inline_edit_flow(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Add a comment directly let comment_id = editor .update(cx, |editor, _window, cx| { let key = test_hunk_key(""); add_test_comment(editor, key, "Original comment", cx) }) .unwrap(); // Set comment to editing mode editor .update(cx, |editor, _window, cx| { editor.set_comment_editing(comment_id, true, cx); }) .unwrap(); // Verify editing flag is set editor .update(cx, |editor, _window, cx| { let key = test_hunk_key(""); let snapshot = editor.buffer().read(cx).snapshot(cx); let comments = editor.comments_for_hunk(&key, &snapshot); assert_eq!(comments.len(), 1); assert!(comments[0].is_editing); }) .unwrap(); // Update the comment editor .update(cx, |editor, _window, cx| { let updated = editor.update_review_comment(comment_id, "Updated comment".to_string(), cx); assert!(updated); }) .unwrap(); // Verify comment was updated and editing flag is cleared editor .update(cx, |editor, _window, cx| { let key = test_hunk_key(""); let snapshot = editor.buffer().read(cx).snapshot(cx); let comments = editor.comments_for_hunk(&key, &snapshot); assert_eq!(comments[0].comment, "Updated comment"); assert!(!comments[0].is_editing); }) .unwrap(); } #[gpui::test] fn test_orphaned_comments_are_cleaned_up(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Create an editor with some text let editor = cx.add_window(|window, cx| { let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); // Add a comment with an anchor on line 2 editor .update(cx, |editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor = snapshot.anchor_after(Point::new(1, 0)); // Line 2 let key = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::empty()), hunk_start_anchor: anchor, }; editor.add_review_comment(key, "Comment on line 2".to_string(), anchor..anchor, cx); assert_eq!(editor.total_review_comment_count(), 1); }) .unwrap(); // Delete all content (this should orphan the comment's anchor) editor .update(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.insert("completely new content", window, cx); }) .unwrap(); // Trigger cleanup editor .update(cx, |editor, _window, cx| { editor.cleanup_orphaned_review_comments(cx); // Comment should be removed because its anchor is invalid assert_eq!(editor.total_review_comment_count(), 0); }) .unwrap(); } #[gpui::test] fn test_orphaned_comments_cleanup_called_on_buffer_edit(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Create an editor with some text let editor = cx.add_window(|window, cx| { let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); // Add a comment with an anchor on line 2 editor .update(cx, |editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor = snapshot.anchor_after(Point::new(1, 0)); // Line 2 let key = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::empty()), hunk_start_anchor: anchor, }; editor.add_review_comment(key, "Comment on line 2".to_string(), anchor..anchor, cx); assert_eq!(editor.total_review_comment_count(), 1); }) .unwrap(); // Edit the buffer - this should trigger cleanup via on_buffer_event // Delete all content which orphans the anchor editor .update(cx, |editor, window, cx| { editor.select_all(&SelectAll, window, cx); editor.insert("completely new content", window, cx); // The cleanup is called automatically in on_buffer_event when Edited fires }) .unwrap(); // Verify cleanup happened automatically (not manually triggered) editor .update(cx, |editor, _window, _cx| { // Comment should be removed because its anchor became invalid // and cleanup was called automatically on buffer edit assert_eq!(editor.total_review_comment_count(), 0); }) .unwrap(); } #[gpui::test] fn test_comments_stored_for_multiple_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); // This test verifies that comments can be stored for multiple different hunks // and that hunk_comment_count correctly identifies comments per hunk. let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); // Create two different hunk keys (simulating two different files) let anchor = snapshot.anchor_before(Point::new(0, 0)); let key1 = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file1.rs").unwrap()), hunk_start_anchor: anchor, }; let key2 = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file2.rs").unwrap()), hunk_start_anchor: anchor, }; // Add comments to first hunk editor.add_review_comment( key1.clone(), "Comment 1 for file1".to_string(), anchor..anchor, cx, ); editor.add_review_comment( key1.clone(), "Comment 2 for file1".to_string(), anchor..anchor, cx, ); // Add comment to second hunk editor.add_review_comment( key2.clone(), "Comment for file2".to_string(), anchor..anchor, cx, ); // Verify total count assert_eq!(editor.total_review_comment_count(), 3); // Verify per-hunk counts let snapshot = editor.buffer().read(cx).snapshot(cx); assert_eq!( editor.hunk_comment_count(&key1, &snapshot), 2, "file1 should have 2 comments" ); assert_eq!( editor.hunk_comment_count(&key2, &snapshot), 1, "file2 should have 1 comment" ); // Verify comments_for_hunk returns correct comments let file1_comments = editor.comments_for_hunk(&key1, &snapshot); assert_eq!(file1_comments.len(), 2); assert_eq!(file1_comments[0].comment, "Comment 1 for file1"); assert_eq!(file1_comments[1].comment, "Comment 2 for file1"); let file2_comments = editor.comments_for_hunk(&key2, &snapshot); assert_eq!(file2_comments.len(), 1); assert_eq!(file2_comments[0].comment, "Comment for file2"); }); } #[gpui::test] fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) { init_test(cx, |_| {}); // This test verifies that hunk_keys_match correctly identifies when two // DiffHunkKeys refer to the same hunk (same file path and anchor point). let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor = snapshot.anchor_before(Point::new(0, 0)); // Create two keys with the same file path and anchor let key1 = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), hunk_start_anchor: anchor, }; let key2 = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), hunk_start_anchor: anchor, }; // Add comment to first key editor.add_review_comment(key1, "Test comment".to_string(), anchor..anchor, cx); // Verify second key (same hunk) finds the comment let snapshot = editor.buffer().read(cx).snapshot(cx); assert_eq!( editor.hunk_comment_count(&key2, &snapshot), 1, "Same hunk should find the comment" ); // Create a key with different file path let different_file_key = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::from_unix_str("other.rs").unwrap()), hunk_start_anchor: anchor, }; // Different file should not find the comment assert_eq!( editor.hunk_comment_count(&different_file_key, &snapshot), 0, "Different file should not find the comment" ); }); } #[gpui::test] fn test_overlay_comments_expanded_state(cx: &mut TestAppContext) { init_test(cx, |_| {}); // This test verifies that set_diff_review_comments_expanded correctly // updates the expanded state of overlays. let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Show overlay editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); }) .unwrap(); // Verify initially expanded (default) editor .update(cx, |editor, _window, _cx| { assert!( editor.diff_review_overlays[0].comments_expanded, "Should be expanded by default" ); }) .unwrap(); // Set to collapsed using the public method editor .update(cx, |editor, _window, cx| { editor.set_diff_review_comments_expanded(false, cx); }) .unwrap(); // Verify collapsed editor .update(cx, |editor, _window, _cx| { assert!( !editor.diff_review_overlays[0].comments_expanded, "Should be collapsed after setting to false" ); }) .unwrap(); // Set back to expanded editor .update(cx, |editor, _window, cx| { editor.set_diff_review_comments_expanded(true, cx); }) .unwrap(); // Verify expanded again editor .update(cx, |editor, _window, _cx| { assert!( editor.diff_review_overlays[0].comments_expanded, "Should be expanded after setting to true" ); }) .unwrap(); } #[gpui::test] fn test_diff_review_multiline_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); // Create an editor with multiple lines of text let editor = cx.add_window(|window, cx| { let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\nline 4\nline 5\n", cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); // Test showing overlay with a multi-line selection (lines 1-3, which are rows 0-2) editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(2), window, cx); }) .unwrap(); // Verify line range editor .update(cx, |editor, _window, cx| { assert!(!editor.diff_review_overlays.is_empty()); assert_eq!(editor.diff_review_line_range(cx), Some((0, 2))); }) .unwrap(); // Dismiss and test with reversed range (end < start) editor .update(cx, |editor, _window, cx| { editor.dismiss_all_diff_review_overlays(cx); }) .unwrap(); // Show overlay with reversed range - should normalize it editor .update(cx, |editor, window, cx| { editor.show_diff_review_overlay(DisplayRow(3)..DisplayRow(1), window, cx); }) .unwrap(); // Verify range is normalized (start <= end) editor .update(cx, |editor, _window, cx| { assert_eq!(editor.diff_review_line_range(cx), Some((1, 3))); }) .unwrap(); } #[gpui::test] fn test_diff_review_drag_state(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); Editor::new(EditorMode::full(), multi_buffer, None, window, cx) }); // Initially no drag state editor .update(cx, |editor, _window, _cx| { assert!(editor.diff_review_drag_state.is_none()); }) .unwrap(); // Start drag at row 1 editor .update(cx, |editor, window, cx| { editor.start_diff_review_drag(DisplayRow(1), window, cx); }) .unwrap(); // Verify drag state is set editor .update(cx, |editor, window, cx| { assert!(editor.diff_review_drag_state.is_some()); let snapshot = editor.snapshot(window, cx); let range = editor .diff_review_drag_state .as_ref() .unwrap() .row_range(&snapshot.display_snapshot); assert_eq!(*range.start(), DisplayRow(1)); assert_eq!(*range.end(), DisplayRow(1)); }) .unwrap(); // Update drag to row 3 editor .update(cx, |editor, window, cx| { editor.update_diff_review_drag(DisplayRow(3), window, cx); }) .unwrap(); // Verify drag state is updated editor .update(cx, |editor, window, cx| { assert!(editor.diff_review_drag_state.is_some()); let snapshot = editor.snapshot(window, cx); let range = editor .diff_review_drag_state .as_ref() .unwrap() .row_range(&snapshot.display_snapshot); assert_eq!(*range.start(), DisplayRow(1)); assert_eq!(*range.end(), DisplayRow(3)); }) .unwrap(); // End drag - should show overlay editor .update(cx, |editor, window, cx| { editor.end_diff_review_drag(window, cx); }) .unwrap(); // Verify drag state is cleared and overlay is shown editor .update(cx, |editor, _window, cx| { assert!(editor.diff_review_drag_state.is_none()); assert!(!editor.diff_review_overlays.is_empty()); assert_eq!(editor.diff_review_line_range(cx), Some((1, 3))); }) .unwrap(); } #[gpui::test] fn test_diff_review_drag_cancel(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); // Start drag editor .update(cx, |editor, window, cx| { editor.start_diff_review_drag(DisplayRow(0), window, cx); }) .unwrap(); // Verify drag state is set editor .update(cx, |editor, _window, _cx| { assert!(editor.diff_review_drag_state.is_some()); }) .unwrap(); // Cancel drag editor .update(cx, |editor, _window, cx| { editor.cancel_diff_review_drag(cx); }) .unwrap(); // Verify drag state is cleared and no overlay was created editor .update(cx, |editor, _window, _cx| { assert!(editor.diff_review_drag_state.is_none()); assert!(editor.diff_review_overlays.is_empty()); }) .unwrap(); } #[gpui::test] fn test_calculate_overlay_height(cx: &mut TestAppContext) { init_test(cx, |_| {}); // This test verifies that calculate_overlay_height returns correct heights // based on comment count and expanded state. let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); _ = editor.update(cx, |editor, _window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let anchor = snapshot.anchor_before(Point::new(0, 0)); let key = DiffHunkKey { file_path: Arc::from(util::rel_path::RelPath::empty()), hunk_start_anchor: anchor, }; // No comments: base height of 2 let height_no_comments = editor.calculate_overlay_height(&key, true, &snapshot); assert_eq!( height_no_comments, 2, "Base height should be 2 with no comments" ); // Add one comment editor.add_review_comment(key.clone(), "Comment 1".to_string(), anchor..anchor, cx); let snapshot = editor.buffer().read(cx).snapshot(cx); // With comments expanded: base (2) + header (1) + 2 per comment let height_expanded = editor.calculate_overlay_height(&key, true, &snapshot); assert_eq!( height_expanded, 2 + 1 + 2, // base + header + 1 comment * 2 "Height with 1 comment expanded" ); // With comments collapsed: base (2) + header (1) let height_collapsed = editor.calculate_overlay_height(&key, false, &snapshot); assert_eq!( height_collapsed, 2 + 1, // base + header only "Height with comments collapsed" ); // Add more comments editor.add_review_comment(key.clone(), "Comment 2".to_string(), anchor..anchor, cx); editor.add_review_comment(key.clone(), "Comment 3".to_string(), anchor..anchor, cx); let snapshot = editor.buffer().read(cx).snapshot(cx); // With 3 comments expanded let height_3_expanded = editor.calculate_overlay_height(&key, true, &snapshot); assert_eq!( height_3_expanded, 2 + 1 + (3 * 2), // base + header + 3 comments * 2 "Height with 3 comments expanded" ); // Collapsed height stays the same regardless of comment count let height_3_collapsed = editor.calculate_overlay_height(&key, false, &snapshot); assert_eq!( height_3_collapsed, 2 + 1, // base + header only "Height with 3 comments collapsed should be same as 1 comment collapsed" ); }); } #[gpui::test] async fn test_move_to_start_end_of_larger_syntax_node_single_cursor(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" fn main() { let x = foo(1, 2); } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test case 1: Move to end of syntax nodes editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 16)..DisplayPoint::new(DisplayRow(1), 16) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(ˇ1, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1ˇ, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2)ˇ; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2);ˇ } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2); }ˇ "#}, cx, ); }); // Test case 2: Move to start of syntax nodes editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2ˇ); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = fooˇ(1, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = ˇfoo(1, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { ˇlet x = foo(1, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() ˇ{ let x = foo(1, 2); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" ˇfn main() { let x = foo(1, 2); } "#}, cx, ); }); } #[gpui::test] async fn test_move_to_start_end_of_larger_syntax_node_two_cursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" fn main() { let x = foo(1, 2); let y = bar(3, 4); } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test case 1: Move to end of syntax nodes with two cursors editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20), DisplayPoint::new(DisplayRow(2), 20)..DisplayPoint::new(DisplayRow(2), 20), ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2ˇ); let y = bar(3, 4ˇ); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2)ˇ; let y = bar(3, 4)ˇ; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2);ˇ let y = bar(3, 4);ˇ } "#}, cx, ); }); // Test case 2: Move to start of syntax nodes with two cursors editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 19)..DisplayPoint::new(DisplayRow(1), 19), DisplayPoint::new(DisplayRow(2), 19)..DisplayPoint::new(DisplayRow(2), 19), ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, ˇ2); let y = bar(3, ˇ4); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = fooˇ(1, 2); let y = barˇ(3, 4); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = ˇfoo(1, 2); let y = ˇbar(3, 4); } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { ˇlet x = foo(1, 2); ˇlet y = bar(3, 4); } "#}, cx, ); }); } #[gpui::test] async fn test_move_to_start_end_of_larger_syntax_node_with_selections_and_strings( cx: &mut TestAppContext, ) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); let text = r#" fn main() { let x = foo(1, 2); let msg = "hello world"; } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; // Test case 1: With existing selection, move_to_end keeps selection editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 12)..DisplayPoint::new(DisplayRow(1), 21) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = «foo(1, 2)ˇ»; let msg = "hello world"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = «foo(1, 2)ˇ»; let msg = "hello world"; } "#}, cx, ); }); // Test case 2: Move to end within a string editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 15)..DisplayPoint::new(DisplayRow(2), 15) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2); let msg = "ˇhello world"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2); let msg = "hello worldˇ"; } "#}, cx, ); }); // Test case 3: Move to start within a string editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(2), 21)..DisplayPoint::new(DisplayRow(2), 21) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2); let msg = "hello ˇworld"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x = foo(1, 2); let msg = "ˇhello world"; } "#}, cx, ); }); } #[gpui::test] async fn test_select_to_start_end_of_larger_syntax_node(cx: &mut TestAppContext) { init_test(cx, |_| {}); let language = Arc::new(Language::new( LanguageConfig::default(), Some(tree_sitter_rust::LANGUAGE.into()), )); // Test Group 1.1: Cursor in String - First Jump (Select to End) let text = r#"let msg = "foo bar baz";"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 14)..DisplayPoint::new(DisplayRow(0), 14) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "fooˇ bar baz";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar bazˇ»";"#}, cx); }); // Test Group 1.2: Cursor in String - Second Jump (Select to End) editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar baz"ˇ»;"#}, cx); }); // Test Group 1.3: Cursor in String - Third Jump (Select to End) editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar baz";ˇ»"#}, cx); }); // Test Group 1.4: Cursor in String - First Jump (Select to Start) editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 18)..DisplayPoint::new(DisplayRow(0), 18) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "foo barˇ baz";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = "«ˇfoo bar» baz";"#}, cx); }); // Test Group 1.5: Cursor in String - Second Jump (Select to Start) editor.update_in(cx, |editor, window, cx| { editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let msg = «ˇ"foo bar» baz";"#}, cx); }); // Test Group 1.6: Cursor in String - Third Jump (Select to Start) editor.update_in(cx, |editor, window, cx| { editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"«ˇlet msg = "foo bar» baz";"#}, cx); }); // Test Group 2.1: Let Statement Progression (Select to End) let text = r#" fn main() { let x = "hello"; } "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let xˇ = "hello"; } "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r##" fn main() { let x« = "hello";ˇ» } "##}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" fn main() { let x« = "hello"; }ˇ» "#}, cx, ); }); // Test Group 2.2a: From Inside String Content Node To String Content Boundary let text = r#"let x = "hello";"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "helˇlo";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "«ˇhel»lo";"#}, cx); }); // Test Group 2.2b: From Edge of String Content Node To String Literal Boundary editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "ˇhello";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = «ˇ"»hello";"#}, cx); }); // Test Group 3.1: Create Selection from Cursor (Select to End) let text = r#"let x = "hello world";"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 14)..DisplayPoint::new(DisplayRow(0), 14) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "helloˇ world";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "hello« worldˇ»";"#}, cx); }); // Test Group 3.2: Extend Existing Selection (Select to End) editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 17) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "he«llo woˇ»rld";"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "he«llo worldˇ»";"#}, cx); }); // Test Group 4.1: Multiple Cursors - All Expand to Different Syntax Nodes let text = r#"let x = "hello"; let y = 42;"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ // Cursor inside string content DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12), // Cursor at let statement semicolon DisplayPoint::new(DisplayRow(0), 18)..DisplayPoint::new(DisplayRow(0), 18), // Cursor inside integer literal DisplayPoint::new(DisplayRow(0), 26)..DisplayPoint::new(DisplayRow(0), 26), ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "helˇlo"; lˇet y = 4ˇ2;"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let x = "hel«loˇ»"; l«et y = 42;ˇ»"#}, cx); }); // Test Group 4.2: Multiple Cursors on Separate Lines let text = r#" let x = "hello"; let y = 42; "# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12), DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9), ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let x = "helˇlo"; let y = 4ˇ2; "#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let x = "hel«loˇ»"; let y = 4«2ˇ»; "#}, cx, ); }); // Test Group 5.1: Nested Function Calls let text = r#"let result = foo(bar("arg"));"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 22)..DisplayPoint::new(DisplayRow(0), 22) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("ˇarg"));"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«argˇ»"));"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«arg"ˇ»));"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«arg")ˇ»);"#}, cx); }); // Test Group 6.1: Block Comments let text = r#"let x = /* multi line comment */;"# .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 16)..DisplayPoint::new(DisplayRow(0), 16) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let x = /* multiˇ line comment */;"#}, cx, ); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections( editor, indoc! {r#" let x = /* multi« line comment */ˇ»;"#}, cx, ); }); // Test Group 6.2: Array/Vector Literals let text = r#"let arr = [1, 2, 3];"#.unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); editor .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_display_ranges([ DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11) ]); }); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let arr = [ˇ1, 2, 3];"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let arr = [«1ˇ», 2, 3];"#}, cx); }); editor.update_in(cx, |editor, window, cx| { editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); }); editor.update(cx, |editor, cx| { assert_text_with_selections(editor, indoc! {r#"let arr = [«1, 2, 3]ˇ»;"#}, cx); }); } #[gpui::test] async fn test_restore_and_next(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" one two three four five "# .unindent(); cx.set_state( &r#" ONE two ˇTHREE four FIVE "# .unindent(), ); cx.set_head_text(&diff_base); cx.update_editor(|editor, window, cx| { editor.set_expand_all_diff_hunks(cx); editor.restore_and_next(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_state_with_diff( r#" - one + ONE two three four - ˇfive + FIVE "# .unindent(), ); cx.update_editor(|editor, window, cx| { editor.restore_and_next(&Default::default(), window, cx); }); cx.run_until_parked(); cx.assert_state_with_diff( r#" - one + ONE two three four ˇfive "# .unindent(), ); } #[gpui::test] async fn test_align_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // 1) one cursor, no action let before = " abc\n abc\nabc\n ˇabc"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(before); // 2) multiple cursors at different rows let before = indoc!( r#" let aˇbc = 123; let xˇyz = 456; let fˇoo = 789; let bˇar = 0; "# ); let after = indoc!( r#" let a ˇbc = 123; let x ˇyz = 456; let f ˇoo = 789; let bˇar = 0; "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // 3) multiple selections at different rows let before = indoc!( r#" let «ˇabc» = 123; let «ˇxyz» = 456; let «ˇfoo» = 789; let «ˇbar» = 0; "# ); let after = indoc!( r#" let «ˇabc» = 123; let «ˇxyz» = 456; let «ˇfoo» = 789; let «ˇbar» = 0; "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // 4) multiple selections at different rows, inverted head let before = indoc!( r#" let «abcˇ» = 123; // comment let «xyzˇ» = 456; let «fooˇ» = 789; let «barˇ» = 0; "# ); let after = indoc!( r#" let «abcˇ» = 123; // comment let «xyzˇ» = 456; let «fooˇ» = 789; let «barˇ» = 0; "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); } #[gpui::test] async fn test_align_selections_multicolumn(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // 1) Multicolumn, one non affected editor row let before = indoc!( r#" name «|ˇ» age «|ˇ» height «|ˇ» note Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy Anything that is not selected Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny "# ); let after = indoc!( r#" name «|ˇ» age «|ˇ» height «|ˇ» note Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy Anything that is not selected Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // 2) not all alignment rows has the number of alignment columns let before = indoc!( r#" name «|ˇ» age «|ˇ» height Matthew «|ˇ» 7 «|ˇ» 2333 Mike «|ˇ» 1234 Miles «|ˇ» 88 «|ˇ» 99 "# ); let after = indoc!( r#" name «|ˇ» age «|ˇ» height Matthew «|ˇ» 7 «|ˇ» 2333 Mike «|ˇ» 1234 Miles «|ˇ» 88 «|ˇ» 99 "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // 3) A aligned column shall stay aligned let before = indoc!( r#" $ ˇa ˇa $ ˇa ˇa $ ˇa ˇa $ ˇa ˇa "# ); let after = indoc!( r#" $ ˇa ˇa $ ˇa ˇa $ ˇa ˇa $ ˇa ˇa "# ); cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); } #[gpui::test] async fn test_align_selections_with_multibyte_chars(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // The reported case: `←` and `π` take more bytes than they take columns, so // aligning on the buffer column padded the first row one space too far. let before = "a ← 1 ˇ# one\nbc ← π ˇ# two"; let after = "a ← 1 ˇ# one\nbc ← π ˇ# two"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // A multi-byte character before the first column also has to shift the // offset that is carried into the second column. let before = "π aˇ bbbˇc\nxy aˇ bˇc"; let after = "π a ˇ bbbˇc\nxy aˇ b ˇc"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // The display map expands tabs before the row is laid out, so a leading tab // counts as its expanded width rather than as one byte. let before = "\taˇbc\nxyzaˇbc"; let after = "\taˇbc\nxyza ˇbc"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // A non-BMP character advances two columns, so counting characters rather // than measuring advances would pad this row twice as far as it needs. let before = "😀ˇb\nxyzˇb"; let after = "😀 ˇb\nxyzˇb"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); // Multi-byte characters after the cursors do not move them. let before = "abˇ←z\ncdˇqz"; cx.set_state(before); cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(before); } #[gpui::test] async fn test_align_selections_with_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; let before = "aaaaaaaaaaaaˇx\nbbbbbˇy"; let after = "aaaaaaaaaaaaˇx\nbbbbb ˇy"; cx.set_state(before); cx.update_editor(|e, _, cx| e.set_wrap_width(Some(100.0.into()), cx)); cx.update_editor(|e, window, cx| { assert!(e.display_text(cx).lines().count() > 2); e.align_selections(&AlignSelections, window, cx) }); cx.assert_editor_state(after); } #[gpui::test] async fn test_custom_fallback_highlights(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {"fn main(self, variable: TType) {ˇ}"}); let variable_color = Hsla::green(); let function_color = Hsla::blue(); let test_cases = [ ("@variable", Some(variable_color)), ("@type", None), ("@type @variable", Some(variable_color)), ("@variable @type", Some(variable_color)), ("@variable @function", Some(function_color)), ("@function @variable", Some(variable_color)), ]; for (test_case, expected) in test_cases { let custom_rust_lang = Arc::into_inner(rust_lang()) .unwrap() .with_highlights_query(format! {r#"(type_identifier) {test_case}"#}.as_str()) .unwrap(); let theme = setup_syntax_highlighting(Arc::new(custom_rust_lang), &mut cx); let expected = expected.map_or_else(Vec::new, |expected_color| { vec![(24..29, HighlightStyle::color(expected_color))] }); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( expected, snapshot.combined_highlights(MultiBufferOffset(0)..snapshot.buffer().len(), &theme), "Test case with '{test_case}' highlights query did not pass", ); }); } } #[gpui::test] async fn test_tsx_nested_jsx_member_expression_highlights(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇ;"); let language = Arc::new( Language::new( LanguageConfig { name: "TSX".into(), matcher: (LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..LanguageMatcher::default() }) .into(), ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), ) .with_highlights_query(include_str!("../../grammars/src/tsx/highlights.scm")) .unwrap(), ); let component_color = Hsla::green(); let theme = Arc::new(SyntaxTheme::new_test(vec![ ("tag.component.jsx", component_color), ("type", Hsla::blue()), ("property", Hsla::red()), ("punctuation.bracket", Hsla::default()), ("punctuation.delimiter", Hsla::default()), ])); setup_syntax_highlighting_with_theme(language, theme.clone(), &mut cx); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( snapshot .combined_highlights(MultiBufferOffset(0)..snapshot.buffer().len(), &theme) .iter() .filter(|(_, style)| *style == HighlightStyle::color(component_color)) .cloned() .collect::>(), vec![ (1..2, HighlightStyle::color(component_color)), (3..4, HighlightStyle::color(component_color)), (5..6, HighlightStyle::color(component_color)), (9..10, HighlightStyle::color(component_color)), (11..12, HighlightStyle::color(component_color)), (13..14, HighlightStyle::color(component_color)), ], ); }); } fn setup_syntax_highlighting( language: Arc, cx: &mut EditorTestContext, ) -> Arc { let syntax = Arc::new(SyntaxTheme::new_test(vec![ ("keyword", Hsla::red()), ("function", Hsla::blue()), ("variable", Hsla::green()), ("number", Hsla::default()), ("operator", Hsla::default()), ("punctuation.bracket", Hsla::default()), ("punctuation.delimiter", Hsla::default()), ])); setup_syntax_highlighting_with_theme(language, syntax.clone(), cx); syntax } fn setup_syntax_highlighting_with_theme( language: Arc, syntax: Arc, cx: &mut EditorTestContext, ) { language.set_theme(&syntax); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.executor().run_until_parked(); cx.update_editor(|editor, window, cx| { editor.set_style( EditorStyle { syntax, ..EditorStyle::default() }, window, cx, ); }); } #[gpui::test] async fn test_toggle_diagnostics_persists_across_settings_change(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, _, _| { assert!( editor.diagnostics_enabled(), "diagnostics should start enabled by default" ); }); cx.update_editor(|editor, window, cx| { editor.toggle_diagnostics(&actions::ToggleDiagnostics, window, cx); assert!( !editor.diagnostics_enabled(), "diagnostics should be disabled after toggle" ); }); update_test_editor_settings(&mut cx, &|settings| { settings.cursor_blink = Some(false); }); cx.run_until_parked(); cx.update_editor(|editor, _, _| { assert!( !editor.diagnostics_enabled(), "diagnostics should remain disabled after settings change" ); }); cx.update_editor(|editor, window, cx| { editor.toggle_diagnostics(&actions::ToggleDiagnostics, window, cx); assert!( editor.diagnostics_enabled(), "diagnostics should be re-enabled after second toggle" ); }); } #[gpui::test] async fn test_columnar_selection_with_multibyte_chars(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // The middle row contains a 2-byte char (ã) before the dragged column. A // column selection that uses byte columns directly puts the ã row's // selection at a different visual position than the ASCII rows; anchoring // in x pixels keeps all rows at the same character offset. cx.set_state(indoc! {" ˇabcde abcde aãcde abcde abcde "}); // Drag column-wise from (row 0, col 0) past the ã column on every row. cx.update_editor(|editor, window, cx| { editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(0), 0), goal_column: 0, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(DisplayRow(4), 4), goal_column: 4, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" «abcdˇ»e «abcdˇ»e «aãcdˇ»e «abcdˇ»e «abcdˇ»e "}); // Control: drag stops before the ã column, where byte columns and x // positions agree. cx.update_editor(|editor, window, cx| { editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(0), 0), goal_column: 0, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(DisplayRow(4), 1), goal_column: 1, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" «aˇ»bcde «aˇ»bcde «aˇ»ãcde «aˇ»bcde «aˇ»bcde "}); } #[gpui::test] async fn test_columnar_selection_past_end_of_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇaaaaaaaaaa bb cccccccccc "}); // Drag from the start of the long first row to a point past the EOL of // the short second row: the mouse handlers encode that as the nearest // valid position (1, 2) plus an unclipped goal column of 8. The rectangle // must keep tracking the mouse x on the long row instead of collapsing to // the short row's width. cx.update_editor(|editor, window, cx| { editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(0), 0), goal_column: 0, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(DisplayRow(1), 2), goal_column: 8, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" «aaaaaaaaˇ»aa «bbˇ» cccccccccc "}); // Starting the drag past the EOL of the short row must anchor that edge // of the rectangle at the click position, not at the short row's EOL. cx.update_editor(|editor, window, cx| { editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(1), 2), goal_column: 8, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(DisplayRow(2), 4), goal_column: 4, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" aaaaaaaaaa bb cccc«ˇcccc»cc "}); } #[gpui::test] async fn test_columnar_selection_with_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" ˇ1. Very long line to show how a wrapped line would look 2. Very long line to show how a wrapped line would look "}); let soft_wrap_second_line_row = |editor: &mut Editor, cx: &mut Context| { editor.set_wrap_width(Some(100.0.into()), cx); let snapshot = editor.display_snapshot(cx); let second_line_row = Point::new(1, 0).to_display_point(&snapshot).row(); assert!( second_line_row.0 > 1, "expected the first line to be soft wrapped" ); second_line_row }; // Dragging a columnar selection across the soft-wrapped rows of the first // line must produce one selection per buffer line, not one per display row. cx.update_editor(|editor, window, cx| { let second_line_row = soft_wrap_second_line_row(editor, cx); editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(0), 0), goal_column: 0, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(second_line_row, 1), goal_column: 1, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" «1ˇ». Very long line to show how a wrapped line would look «2ˇ». Very long line to show how a wrapped line would look "}); cx.update_editor(|editor, window, cx| { let second_line_row = soft_wrap_second_line_row(editor, cx); editor.select( SelectPhase::BeginColumnar { position: DisplayPoint::new(DisplayRow(0), 0), goal_column: 0, reset: true, mode: ColumnarMode::FromMouse, }, window, cx, ); editor.select( SelectPhase::Update { position: DisplayPoint::new(second_line_row, 0), goal_column: 0, scroll_delta: gpui::Point::default(), }, window, cx, ); }); cx.assert_editor_state(indoc! {" ˇ1. Very long line to show how a wrapped line would look ˇ2. Very long line to show how a wrapped line would look "}); } #[gpui::test] async fn test_toggle_markdown_block_quote(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; // No-op with no language cx.set_state(indoc! {" «helloˇ» world "}); cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «helloˇ» world "}); // No-op in non-Markdown language (Rust) cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); cx.set_state(indoc! {" «helloˇ» world "}); cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «helloˇ» world "}); cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); // Line is quoted with an empty selection cx.set_state(indoc! {" helˇlo world "}); cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «> hello worldˇ» "}); // Line is unquoted with an empty selection cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «hello worldˇ» "}); // Multi-line selection is quoted, including blank lines cx.set_state(indoc! {" «first thirdˇ» "}); cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «> first > > thirdˇ» "}); // Multi-line selection is unquoted, including blank lines cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «first thirdˇ» "}); // A multi-line selection, including a mixture of quoted and unquoted lines // and a mixture of empty and non-empty lines, normalizes each line to a // single quote. cx.set_state(indoc! {" «> first second > > third >fourthˇ» "}); cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «> first > second > > > third > fourthˇ» "}); // A multi-line selection is unquoted. cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); cx.assert_editor_state(indoc! {" «first second third fourthˇ» "}); } #[track_caller] fn assert_select_delimiters(around: bool, before: &str, after: &str, cx: &mut EditorTestContext) { let _state_context = cx.set_state(before); if around { cx.dispatch_action(SelectAroundDelimiters); } else { cx.dispatch_action(SelectInsideDelimiters); } cx.assert_editor_state(after); } #[gpui::test] async fn test_select_delimiters(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; // Inside. assert_select_delimiters(false, "foo(ˇbar);", "foo(«barˇ»);", &mut cx); assert_select_delimiters(false, "foo(a, ˇb, c);", "foo(«a, b, cˇ»);", &mut cx); assert_select_delimiters(false, "foo([1, ˇ2, 3]);", "foo([«1, 2, 3ˇ»]);", &mut cx); assert_select_delimiters(false, "let x = { aˇ: 1 };", "let x = {« a: 1 ˇ»};", &mut cx); assert_select_delimiters(false, "let xˇ = 42;", "let xˇ = 42;", &mut cx); assert_select_delimiters(false, "foo(a, «bˇ», c);", "foo(«a, b, cˇ»);", &mut cx); assert_select_delimiters( false, "const s = \"hello ˇworld\";", "const s = \"«hello worldˇ»\";", &mut cx, ); assert_select_delimiters( false, "const s = \"ˇhello world\";", "const s = \"«hello worldˇ»\";", &mut cx, ); assert_select_delimiters( false, "const s = \"hello worldˇ\";", "const s = \"«hello worldˇ»\";", &mut cx, ); assert_select_delimiters( false, "console.log(\"deˇbug\");", "console.log(\"«debugˇ»\");", &mut cx, ); // Around. assert_select_delimiters(true, "foo(ˇbar);", "foo«(bar)ˇ»;", &mut cx); assert_select_delimiters(true, "foo([1, ˇ2, 3]);", "foo(«[1, 2, 3]ˇ»);", &mut cx); assert_select_delimiters(true, "let x = {ˇ a: 1 };", "let x = «{ a: 1 }ˇ»;", &mut cx); assert_select_delimiters(true, "let xˇ = 42;", "let xˇ = 42;", &mut cx); assert_select_delimiters( true, "console.log(\"deˇbug\");", "console.log(«\"debug\"ˇ»);", &mut cx, ); } #[gpui::test] async fn test_select_delimiters_in_markdown(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); // Inside. assert_select_delimiters( false, r#"This is "ˇhello, world!"."#, r#"This is "«hello, world!ˇ»"."#, &mut cx, ); assert_select_delimiters( false, r#"This is "hello, ˇworld!"."#, r#"This is "«hello, world!ˇ»"."#, &mut cx, ); assert_select_delimiters( false, r#"This is "hello, world!ˇ"."#, r#"This is "«hello, world!ˇ»"."#, &mut cx, ); assert_select_delimiters( false, r#"This is ˇ"hello, world!"."#, r#"This is "«hello, world!ˇ»"."#, &mut cx, ); assert_select_delimiters( false, r#"This is "hello, world!"ˇ."#, r#"This is "«hello, world!ˇ»"."#, &mut cx, ); assert_select_delimiters( false, r#"This is 'hello, ˇworld!'."#, r#"This is '«hello, world!ˇ»'."#, &mut cx, ); assert_select_delimiters( false, r#"This is `hello, ˇworld!`."#, r#"This is `«hello, world!ˇ»`."#, &mut cx, ); assert_select_delimiters( false, r#"This is ("hello, ˇworld!")."#, r#"This is ("«hello, world!ˇ»")."#, &mut cx, ); assert_select_delimiters( false, r#"This is hello, ˇworld!."#, r#"This is hello, ˇworld!."#, &mut cx, ); // Around. assert_select_delimiters( true, r#"This is "hello, ˇworld!"."#, r#"This is «"hello, world!"ˇ»."#, &mut cx, ); assert_select_delimiters( true, r#"This is 'hello, ˇworld!'."#, r#"This is «'hello, world!'ˇ»."#, &mut cx, ); assert_select_delimiters( true, r#"This is `hello, ˇworld!`."#, r#"This is «`hello, world!`ˇ»."#, &mut cx, ); } #[gpui::test] async fn test_select_delimiters_expansion(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; let _state_context = cx.set_state("foo([1, ˇ2, 3]);"); cx.dispatch_action(SelectInsideDelimiters); cx.assert_editor_state("foo([«1, 2, 3ˇ»]);"); cx.dispatch_action(SelectInsideDelimiters); cx.assert_editor_state("foo(«[1, 2, 3]ˇ»);"); let _state_context = cx.set_state("foo([1, ˇ2, 3]);"); cx.dispatch_action(SelectInsideDelimiters); cx.assert_editor_state("foo([«1, 2, 3ˇ»]);"); cx.dispatch_action(SelectAroundDelimiters); cx.assert_editor_state("foo(«[1, 2, 3]ˇ»);"); cx.dispatch_action(SelectAroundDelimiters); cx.assert_editor_state("foo«([1, 2, 3])ˇ»;"); let _state_context = cx.set_state("foo(x, { ˇa: 1 });"); cx.dispatch_action(SelectInsideDelimiters); cx.assert_editor_state("foo(x, {« a: 1 ˇ»});"); cx.dispatch_action(SelectAroundDelimiters); cx.assert_editor_state("foo(x, «{ a: 1 }ˇ»);"); cx.dispatch_action(SelectInsideDelimiters); cx.assert_editor_state("foo(«x, { a: 1 }ˇ»);"); } #[gpui::test] async fn test_in_preview_key_context(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings: &mut SettingsContent| { settings.preview_tabs.get_or_insert_default().enabled = Some(true); }); }); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), serde_json::json!({ "test.txt": "hello", }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path_preview( (worktree_id, rel_path("test.txt")), None, true, true, true, window, cx, ) }) .unwrap() .await .downcast::() .unwrap(); let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); pane.read_with(cx, |pane, _| { assert!(pane.is_active_preview_item(editor.entity_id())); }); cx.focus(&editor); editor.update_in(cx, |editor, window, cx| { assert!( editor.key_context(window, cx).contains("in_preview"), "Expected 'in_preview' context when the editor is the pane's preview item" ); }); pane.update(cx, |pane, _cx| { pane.unpreview_item_if_preview(editor.entity_id()); }); editor.update_in(cx, |editor, window, cx| { assert!( !editor.key_context(window, cx).contains("in_preview"), "Expected no 'in_preview' context after the tab is unpreviewed" ); }); } async fn setup_range_format_test_with_capabilities( cx: &mut TestAppContext, capabilities: lsp::ServerCapabilities, ) -> ( Entity, Entity, &mut gpui::VisualTestContext, lsp::FakeLanguageServer, ) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_file(path!("/file.rs"), Default::default()).await; let project = Project::test(fs, [path!("/").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/file.rs"), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); }); let fake_server = fake_servers.next().await.unwrap(); (project, editor, cx, fake_server) } async fn setup_range_format_test( cx: &mut TestAppContext, ) -> ( Entity, Entity, &mut gpui::VisualTestContext, lsp::FakeLanguageServer, ) { setup_range_format_test_with_capabilities( cx, lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ) .await } /// Like `setup_range_format_test`, but backs the buffer with a FakeFs git /// repository so that `GitStore::get_unstaged_diff` returns a real diff. /// `head_content` sets the HEAD base, `index_content` sets the staged base. /// The buffer starts empty; the caller must `editor.set_text(...)` to set the /// working-tree content (the diff recomputes from buffer changes). async fn setup_range_format_test_with_git<'a>( cx: &'a mut TestAppContext, head_content: &str, index_content: &str, ) -> ( Entity, Entity, &'a mut gpui::VisualTestContext, lsp::FakeLanguageServer, ) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "file.rs": "", }), ) .await; fs.set_head_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", head_content.to_string())], "deadbeef", ); fs.set_index_for_repo( std::path::Path::new(path!("/project/.git")), &[("file.rs", index_content.to_string())], ); let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), document_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/file.rs"), cx) }) .await .unwrap(); // Open the unstaged diff so GitStore tracks this buffer. Without this, // `get_unstaged_diff` returns None and compute_format_target cannot // produce range-based FormatTarget. project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); editor.update_in(cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx), cx); }); let fake_server = fake_servers.next().await.unwrap(); (project, editor, cx, fake_server) } fn refresh_editor_actions(cx: &mut VisualTestContext) { cx.executor().run_until_parked(); cx.update(|window, cx| { let _ = window.draw(cx); }); } fn lsp_line_edit(start_line: u32, end_line: u32, text: &str) -> lsp::TextEdit { lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(start_line, 0), lsp::Position::new(end_line, 0), ), text.to_string(), ) } async fn assert_range_format_merge( cx: &mut TestAppContext, head_content: &str, buffer_text: &str, responses: Vec>, expected_text: &str, description: &str, ) { let expected_requests = responses.len(); let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, head_content, head_content).await; update_test_language_settings(cx, &|settings| { settings.defaults.format_on_save = Some(FormatOnSave::Modifications); }); editor.update_in(cx, |editor, window, cx| { editor.set_text(buffer_text, window, cx); }); cx.run_until_parked(); assert!(cx.read(|cx| editor.is_dirty(cx))); let request_count = Arc::new(AtomicUsize::new(0)); let responses = Arc::new(responses); let mut responded_rx = fake_server.set_request_handler::({ let request_count = request_count.clone(); move |params, _| { let count = request_count.fetch_add(1, atomic::Ordering::SeqCst); let responses = responses.clone(); async move { assert_eq!( params.text_document.uri, lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() ); match responses.get(count) { Some(edits) => Ok(Some(edits.clone())), None => panic!("unexpected range formatting request #{}", count + 1), } } } }); let save = editor .update_in(cx, |editor, window, cx| { editor.save( SaveOptions { format: true, autosave: false, force_format: false, }, project.clone(), window, cx, ) }) .unwrap(); for _ in 0..expected_requests { responded_rx.next().await; } save.await; assert_eq!( request_count.load(atomic::Ordering::SeqCst), expected_requests, "{description}" ); assert_eq!( editor.update(cx, |editor, cx| editor.text(cx)), expected_text, "{description}" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } async fn init_bookmarks_tab_test( cx: &mut TestAppContext, files: serde_json::Value, ) -> ( Entity, Entity, Entity, Entity, VisualTestContext, ) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/a"), files).await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .expect("window should still be open"); let mut cx = VisualTestContext::from_window(*window, cx); let editor = open_bookmarks_test_editor(&workspace, &project, "main.rs", &mut cx).await; let pane = workspace.update(&mut cx, |workspace, _| workspace.active_pane().clone()); (workspace, pane, project, editor, cx) } async fn open_bookmarks_test_editor( workspace: &Entity, project: &Entity, path: &str, cx: &mut VisualTestContext, ) -> Entity { let worktree_id = project.update(cx, |project, cx| { project .worktrees(cx) .next() .expect("project should have at least one worktree") .read(cx) .id() }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path(path)), cx) }) .await .expect("buffer should open"); let editor = workspace.update_in(cx, |_workspace, window, cx| { let multibuffer = MultiBuffer::build_from_buffer(buffer, cx); cx.new(|cx| { Editor::new( EditorMode::full(), multibuffer, Some(project.clone()), window, cx, ) }) }); let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); pane.update_in(cx, |pane, window, cx| { pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); }); editor } fn open_bookmarks_tab(workspace: &Entity, cx: &mut VisualTestContext) { workspace.update_in(cx, |workspace, window, cx| { Editor::view_bookmarks(workspace, &actions::ViewBookmarks, window, cx); }); } fn toggle_bookmark_on_row(editor: &Entity, row: u32, cx: &mut VisualTestContext) { editor.update_in(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(row, 0)..Point::new(row, 0)]) }); editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); } fn find_bookmarks_editor( workspace: &Entity, cx: &mut VisualTestContext, ) -> Option> { workspace.update(cx, |workspace, cx| { workspace .items_of_type::(cx) .find(|editor| editor.read(cx).bookmarks_tab_state.is_some()) }) } fn excerpt_count(editor: &Entity, cx: &mut VisualTestContext) -> usize { editor.update(cx, |editor, cx| { editor.buffer.read(cx).snapshot(cx).excerpts().count() }) } fn bookmarked_paths(editor: &Entity, cx: &mut VisualTestContext) -> Vec { editor.update(cx, |editor, cx| { let mut paths = editor .buffer .read(cx) .snapshot(cx) .buffers_with_paths() .map(|(_, path_key)| path_key.path.as_unix_str().to_string()) .collect::>(); paths.sort(); paths }) } fn cursor_line_text(editor: &Entity, cx: &mut VisualTestContext) -> String { editor.update(cx, |editor, cx| { let snapshot = editor.buffer.read(cx).snapshot(cx); let head = editor .selections .newest::(&editor.display_snapshot(cx)) .head(); snapshot .text_for_range(Point::new(head.row, 0)..Point::new(head.row + 1, 0)) .collect::() .trim_end() .to_string() }) } fn close_bookmarks_editor( pane: &Entity, bookmarks_editor: Entity, cx: &mut VisualTestContext, ) { let weak_bookmarks_editor = bookmarks_editor.downgrade(); pane.update_in(cx, |pane, window, cx| { pane.remove_item(weak_bookmarks_editor.entity_id(), false, false, window, cx); }); drop(bookmarks_editor); cx.executor() .advance_clock(workspace::SERIALIZATION_THROTTLE_TIME); cx.run_until_parked(); cx.update(|_window, _cx| {}); weak_bookmarks_editor.assert_released(); } fn open_and_find_bookmarks_tab( workspace: &Entity, cx: &mut VisualTestContext, ) -> Entity { open_bookmarks_tab(workspace, cx); cx.run_until_parked(); find_bookmarks_editor(workspace, cx) .expect("bookmarks editor should exist after view_bookmarks") } fn bookmark_path(path: &str) -> Arc { Arc::from(std::path::Path::new(path)) } fn serialized_rows(rows: &[u32]) -> Vec { rows.iter() .map(|&row| SerializedBookmark { row, label: String::new(), }) .collect() } async fn load_test_bookmarks( bookmark_store: &Entity, entries: Vec<(Arc, Vec)>, cx: &mut VisualTestContext, ) { let entries = entries .into_iter() .map(|(path, rows)| (path, serialized_rows(&rows))) .collect::>(); bookmark_store .update(cx, |store, cx| store.load_serialized_bookmarks(entries, cx)) .await .expect("loading serialized bookmarks should succeed"); } fn failed_paths( bookmark_store: &Entity, cx: &mut VisualTestContext, ) -> Vec> { bookmark_store.read_with(cx, |store, _| { let mut paths = store .paths_failed_to_open() .iter() .cloned() .collect::>(); paths.sort(); paths }) } fn highlighted_display_rows_of( editor: &Entity, cx: &mut VisualTestContext, ) -> Vec { editor.update_in(cx, |editor, window, cx| { editor .highlighted_display_rows(window, cx) .into_keys() .collect() }) } fn display_row_for( editor: &Entity, point: Point, cx: &mut VisualTestContext, ) -> DisplayRow { editor.update(cx, |editor, cx| { editor .display_snapshot(cx) .point_to_display_point(point, text::Bias::Left) .row() }) } fn last_display_row(editor: &Entity, cx: &mut VisualTestContext) -> DisplayRow { let last_point = editor.update(cx, |editor, cx| { editor.buffer.read(cx).snapshot(cx).max_point() }); display_row_for(editor, last_point, cx) } #[gpui::test] async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.update_editor(|editor, _, cx| { let bounds = size(px(1800.), px(900.)); let settled = |width: f32, height: f32, editor_width: f32, bounds: Size| { Some(SettledScrollRange { range: size(px(width), px(height)), editor_width: px(editor_width), editor_bounds_size: bounds, }) }; editor.set_search_results_status( SearchResultsStatus { pending: true, ..SearchResultsStatus::default() }, cx, ); assert_eq!( editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds), settled(1780., 100., 1786., bounds), "a hold that engages before any settled frame must freeze on the first \ state it sees instead of falling through to the live churning range" ); assert_eq!( editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1776.), bounds), settled(1780., 100., 1786., bounds), "an unwrapped interpolation spike and a gutter-driven viewport change \ mid-churn must not leak into the held state; comparing a settled width \ against a viewport from another gutter regime blinks the scrollbar" ); assert_eq!( editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), settled(1780., 100., 1786., bounds), "while held, the settled pair stays even when the live state shrinks" ); let resized_bounds = size(px(1300.), px(900.)); assert_eq!( editor.frozen_scroll_range(true, size(px(1300.), px(160.)), px(1276.), resized_bounds), settled(1300., 160., 1276., resized_bounds), "a window resize invalidates the frozen state even mid-churn, otherwise the \ scrollbar thumbs stay sized for the old window until the search settles" ); assert_eq!( editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1276.), resized_bounds), settled(1300., 160., 1276., resized_bounds), "after re-freezing on the resize, churn at the same bounds stays frozen again" ); editor.set_search_results_status(SearchResultsStatus::default(), cx); assert_eq!( editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), None, "once released and not rewrapping, the live state settles" ); assert_eq!( editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1786.), bounds), settled(1770., 160., 1776., bounds), "a rewrap after release keeps the last settled pair frozen" ); }); } #[gpui::test] async fn test_lsp_show_document(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let target_path = EditorLspTestContext::root_path() .join("dir") .join("target.rs"); let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); fs.as_fake() .insert_file(&target_path, b"fn target() {}".to_vec()) .await; let response = cx .lsp .server .request::( lsp::ShowDocumentParams { uri: lsp::Uri::from_file_path(&target_path).unwrap(), external: None, take_focus: Some(true), selection: Some(lsp::Range::new( lsp::Position::new(0, 3), lsp::Position::new(0, 9), )), }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .expect("show document request should not error"); assert_eq!(response, lsp::ShowDocumentResult { success: true }); cx.run_until_parked(); cx.update_workspace(|workspace, _, cx| { let editor = workspace .active_item_as::(cx) .expect("an editor should be opened for the shown document"); editor.update(cx, |editor, cx| { let path = editor .buffer() .read(cx) .as_singleton() .expect("a singleton buffer should be opened") .read(cx) .file() .expect("the opened buffer should have a file") .path() .clone(); assert_eq!(path.as_ref(), rel_path("dir/target.rs")); assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), vec![Point::new(0, 3)..Point::new(0, 9)] ); }); }); } #[gpui::test] async fn test_lsp_show_document_external(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let initial_item_id = cx .update_workspace(|workspace, _, cx| workspace.active_item(cx).map(|item| item.item_id())); let response = cx .lsp .server .request::( lsp::ShowDocumentParams { uri: "https://zed.dev/docs".parse::().unwrap(), external: Some(true), take_focus: None, selection: None, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .expect("show document request should not error"); assert_eq!(response, lsp::ShowDocumentResult { success: true }); assert_eq!(cx.opened_url(), Some("https://zed.dev/docs".to_string())); cx.run_until_parked(); cx.update_workspace(|workspace, _, cx| { assert_eq!( workspace.active_item(cx).map(|item| item.item_id()), initial_item_id, "external documents should not open any workspace items" ); }); } #[gpui::test] async fn test_lsp_show_document_without_take_focus(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let target_path = EditorLspTestContext::root_path() .join("dir") .join("target.rs"); let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); fs.as_fake() .insert_file(&target_path, b"fn target() {}".to_vec()) .await; let initial_editor = cx.editor.clone(); let response = cx .lsp .server .request::( lsp::ShowDocumentParams { uri: lsp::Uri::from_file_path(&target_path).unwrap(), external: None, take_focus: None, selection: None, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .expect("show document request should not error"); assert_eq!(response, lsp::ShowDocumentResult { success: true }); cx.run_until_parked(); cx.update_workspace(|workspace, _, cx| { let opened_editor = workspace .active_item_as::(cx) .expect("an editor should be opened for the shown document"); assert_ne!( opened_editor.entity_id(), initial_editor.entity_id(), "the shown document should become the active item" ); }); } #[gpui::test] async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { init_test(cx, |_| {}); let cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; let response = cx .lsp .server .request::( lsp::ShowDocumentParams { uri: "untitled:some-document".parse::().unwrap(), external: None, take_focus: None, selection: None, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .expect("show document request should not error"); assert_eq!(response, lsp::ShowDocumentResult { success: false }); } async fn register_document_highlight_capability( lsp: &lsp::FakeLanguageServer, registration_id: &str, language: &str, ) { lsp.request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: registration_id.to_string(), method: "textDocument/documentHighlight".to_string(), register_options: Some(json!({ "documentSelector": [{ "language": language, "scheme": "file" }], })), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); } fn document_highlight_count(cx: &mut EditorLspTestContext) -> usize { cx.editor(|editor, _, _| { editor .background_highlights .get(&HighlightKey::DocumentHighlightRead) .map_or(0, |(_, ranges)| ranges.len()) }) } #[track_caller] fn assert_add_selection_pair( editor: &mut Editor, columns: [Range; 2], above: bool, reversed: bool, window: &mut Window, cx: &mut Context, ) { let ranges = columns .into_iter() .enumerate() .map(|(row, columns)| { Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) }) .collect::>(); let source = &ranges[usize::from(above)]; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([if reversed { source.end..source.start } else { source.clone() }]); }); add_selection_in_direction(editor, above, true, window, cx); let selections = editor.selections.all::(&editor.display_snapshot(cx)); assert_eq!( selections .iter() .map(|selection| (selection.range(), selection.reversed)) .collect::>(), ranges .into_iter() .map(|range| { let reversed = reversed && !range.is_empty(); (range, reversed) }) .collect::>(), "above={above}, reversed={reversed}", ); } #[track_caller] fn assert_add_selection_ranges( editor: &mut Editor, expected: &[Range], cx: &mut Context, ) { assert_eq!( editor .selections .ranges::(&editor.display_snapshot(cx)), expected, ); } fn stored_selection_ranges(editor: &Editor, cx: &App) -> Vec> { let buffer = editor.buffer.read(cx).snapshot(cx); editor .selections .disjoint_anchors() .iter() .chain(editor.selections.pending_anchor()) .map(|selection| selection.range().to_point(&buffer)) .collect() } fn add_selection_in_direction( editor: &mut Editor, above: bool, skip_soft_wrap: bool, window: &mut Window, cx: &mut Context, ) { if above { editor.add_selection_above(&AddSelectionAbove { skip_soft_wrap }, window, cx); } else { editor.add_selection_below(&AddSelectionBelow { skip_soft_wrap }, window, cx); } } fn begin_add_selection_overlap( editor: &mut Editor, row: u32, columns: Range, window: &mut Window, cx: &mut Context, ) { editor.begin_selection( DisplayPoint::new(DisplayRow(row), columns.start), true, 1, window, cx, ); editor.update_selection( DisplayPoint::new(DisplayRow(row), columns.end), 0, gpui::Point::::default(), window, cx, ); } fn multiline_add_selection_history_states() -> [(bool, &'static str, &'static str, &'static str); 2] { [ ( false, "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef", "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef", "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef", ), ( true, "abcdef\nuvwxyz\ndefg«hi\naˇ»bc", "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", "a«bcdˇ»ef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", ), ] } #[gpui::test] async fn test_display_row_for_inline_code_action(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); update_test_language_settings(cx, &|settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::None); settings.defaults.preferred_line_length = Some(25); }); // 20 spaces indent, then "123456789" let text = " 123456789"; let editor = cx.add_window(|window, cx| { build_editor( multi_buffer::MultiBuffer::build_simple(text, cx), window, cx, ) }); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); // The text wraps after 25 columns. // Indent is 20, plus "12345" is 25. So it wraps before "6789". // Buffer point at column 26 (the '7'). let buffer_point = Point::new(0, 26); let display_row = snapshot.display_row_for_inline_code_action(buffer_point); // With SoftWrapIndent::None, the wrapped line has 0 indent (0 < 4), // so it should snap back to the start of the physical line (DisplayRow 0). assert_eq!(display_row, Some(DisplayRow(0))); // Change to SoftWrapIndent::Same, which maintains the 20-space indent update_test_language_settings(cx, &|settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::Same); settings.defaults.preferred_line_length = Some(25); }); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); let display_row = snapshot.display_row_for_inline_code_action(buffer_point); // With SoftWrapIndent::Same, there is enough space in the gutter, // so it should render on the wrapped display row (DisplayRow 1). assert_eq!(display_row, Some(DisplayRow(1))); // 2 spaces of indent at column 0 (< 4), but ExtraTwo wrap adds extra indent (2 + 4 = 6 >= 4) on wrapped line update_test_language_settings(cx, &|settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::ExtraTwo); settings.defaults.tab_size = std::num::NonZeroU32::new(2); settings.defaults.preferred_line_length = Some(25); }); let text_with_2_spaces = " 12345678901234567890123456789\n123456789"; let editor = cx.add_window(|window, cx| { build_editor( multi_buffer::MultiBuffer::build_simple(text_with_2_spaces, cx), window, cx, ) }); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); let buffer_point = Point::new(0, 26); let display_row = snapshot.display_row_for_inline_code_action(buffer_point); // Physical line 0 has 2 spaces indent (< 4), but wrapped DisplayRow(1) has 6 spaces (>= 4). // The code action helper should stay on physical line 0 and render on DisplayRow(1). assert_eq!(display_row, Some(DisplayRow(1))); } #[gpui::test] async fn test_display_row_for_inline_code_action_with_block_above(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); update_test_language_settings(cx, &|settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::ExtraOne); settings.defaults.preferred_line_length = Some(25); }); let text = "1234567890123456789012345678"; let editor = cx.add_window(|window, cx| { build_editor( multi_buffer::MultiBuffer::build_simple(text, cx), window, cx, ) }); _ = editor.update(cx, |editor, _window, cx| { let buffer_snapshot = editor.buffer.read(cx).snapshot(cx); editor.insert_blocks( [BlockProperties { style: BlockStyle::Fixed, placement: BlockPlacement::Above(buffer_snapshot.anchor_before(Point::new(0, 0))), height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, }], None, cx, ); }); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); // DisplayRow 0 is the block, DisplayRow 1 is the first text row. // The text wraps at column 25 with ExtraOne (4 spaces continuation). // Cursor at column 5 is on the first text row (DisplayRow 1), not a // continuation. The line has 0 indent (< 4) and the cursor is not on a // wrapped continuation, so no valid row exists in this single-line buffer. assert_eq!( snapshot.display_row_for_inline_code_action(Point::new(0, 5)), None ); // Cursor at column 26 is on the soft-wrapped continuation row (DisplayRow 2). // It has 4 spaces of continuation indent (>= 4), so the code action is placed here. assert_eq!( snapshot.display_row_for_inline_code_action(Point::new(0, 26)), Some(DisplayRow(2)) ); } #[gpui::test] async fn test_soft_wrap_indent_updated_on_language_changed(cx: &mut gpui::TestAppContext) { // Configure Rust to have a different continuation indent behavior than the default. init_test(cx, |settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::Same); settings.defaults.preferred_line_length = Some(20); settings.languages.0.insert( "Rust".into(), LanguageSettingsContent { soft_wrap_indent: Some(language::language_settings::SoftWrapIndent::None), ..Default::default() }, ); }); let mut cx = EditorTestContext::new(cx).await; cx.set_state("ˇ let a_long_variable = 123456789;\n"); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::Same ); let snapshot = editor.display_snapshot(cx); assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(4)); }); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(rust_lang()), cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::None ); let snapshot = editor.display_snapshot(cx); assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); }); } #[gpui::test] async fn test_soft_wrap_indent_updated_when_first_excerpt_changes(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.soft_wrap_indent = Some(language::language_settings::SoftWrapIndent::Same); settings.defaults.preferred_line_length = Some(20); settings.languages.0.insert( "Rust".into(), LanguageSettingsContent { soft_wrap_indent: Some(language::language_settings::SoftWrapIndent::None), ..Default::default() }, ); }); let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadWrite)); let editor = cx.add_window(|window, cx| build_editor(multibuffer.clone(), window, cx)); // Initially empty multibuffer uses global defaults (Same) editor .update(cx, |editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::Same ); }) .unwrap(); let buffer = cx.new(|cx| { language::Buffer::local(" let a_long_variable = 123456789;\n", cx) .with_language(rust_lang(), cx) }); let (buffer_id, max_point) = buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.max_point())); multibuffer.update(cx, |mb, cx| { mb.set_excerpts_for_buffer(buffer, [Point::new(0, 0)..max_point], 0, cx); }); // Adding the Rust excerpt should update the editor's soft_wrap_indent to None editor .update(cx, |editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::None ); }) .unwrap(); // Continuation line has 0 indent instead of inheriting the 4 spaces from the start of the line let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); // Removing the excerpt returns the editor to global defaults (Same) multibuffer.update(cx, |mb, cx| { mb.remove_excerpts_for_buffer(buffer_id, cx); }); editor .update(cx, |editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::Same ); }) .unwrap(); } #[gpui::test] async fn test_soft_wrap_indent_updated_on_file_move_between_directories( cx: &mut gpui::TestAppContext, ) { init_test(cx, |settings| { settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); settings.defaults.preferred_line_length = Some(20); }); let fs = FakeFs::new(cx.executor()); let root = Path::new("/root"); fs.insert_tree( root, serde_json::json!({ "dir_a": { ".zed": { "settings.json": "{\n \"soft_wrap_indent\": \"same\"\n}" }, "test.txt": " let a_long_variable = 123456789;\n" }, "dir_b": { ".zed": { "settings.json": "{\n \"soft_wrap_indent\": \"none\"\n}" } } }), ) .await; let project = Project::test(fs.clone(), [root], cx).await; let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path("dir_a/test.txt")), cx) }) .await .unwrap(); let editor = cx.add_window(|window, cx| { build_editor_with_project( project.clone(), MultiBuffer::build_from_buffer(buffer, cx), window, cx, ) }); cx.run_until_parked(); // In dir_a, soft_wrap_indent is "same", so continuation row has 4 spaces indent editor .update(cx, |editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::Same ); }) .unwrap(); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(4)); // Move test.txt from dir_a to dir_b let entry_id = project .read_with(cx, |project, cx| { project .entry_for_path(&(worktree_id, rel_path("dir_a/test.txt")).into(), cx) .map(|e| e.id) }) .unwrap(); project .update(cx, |project, cx| { project.rename_entry( entry_id, (worktree_id, rel_path("dir_b/test.txt")).into(), cx, ) }) .await .unwrap(); cx.run_until_parked(); // After moving to dir_b, soft_wrap_indent should update to "none" editor .update(cx, |editor, _window, cx| { assert_eq!( editor.soft_wrap_indent(cx), language::language_settings::SoftWrapIndent::None ); }) .unwrap(); let snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); }