vim: Multiline operation improvements (#24518)

Closes #15711

Discussed changes to match neovim in
https://github.com/zed-industries/zed/pull/24481#issuecomment-2644504695
-- `vi{` matches neovim with treesitter instead of vanilla neovim.
Change and delete matches standard neovim.

Not sure if this is the best way to do it, implemented post processing
to change and delete objects.
I think another way would be adjust the range to trim the trailing
newline char on change and delete operations, instead of having to add
it back.

||Before|After|
|---|---|---|

|initial|![image](https://github.com/user-attachments/assets/0bab37b7-c0ac-4992-a365-b7ec304a6800)||
| `vi{` |
![image](https://github.com/user-attachments/assets/4c802fcd-fa7e-45ba-b7d4-3283ed538e10)
|
![image](https://github.com/user-attachments/assets/4394bb6e-418b-4463-9737-f9bdfc6d31c2)
|
| `ci{` |
![image](https://github.com/user-attachments/assets/b5eabb58-4a93-4c98-80b6-f34a6525b1fb)
|
![image](https://github.com/user-attachments/assets/79af57e4-260c-4432-af66-eba5285d97a0)
|
| `di{` |
![image](https://github.com/user-attachments/assets/190a70e7-71fd-47fe-9d6c-2082f2034d0f)
|
![image](https://github.com/user-attachments/assets/775b86a9-68c1-4397-a44b-c645a772de63)
|

Release Notes:

- vim: Improved multi-line operations
This commit is contained in:
5brian 2025-02-10 10:45:06 -05:00 committed by GitHub
parent d292b7c96d
commit 69d415c8d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -407,6 +407,9 @@ impl Object {
if let Some(range) = self.range(map, selection.clone(), around) {
selection.start = range.start;
selection.end = range.end;
if !around && self.is_multiline() {
preserve_indented_newline(map, selection);
}
true
} else {
false
@ -414,6 +417,49 @@ impl Object {
}
}
/// Returns a range without the final newline char.
///
/// If the selection spans multiple lines and is preceded by an opening brace (`{`),
/// this function will trim the selection to exclude the final newline
/// in order to preserve a properly indented line.
fn preserve_indented_newline(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
let (start_point, end_point) = (selection.start.to_point(map), selection.end.to_point(map));
if start_point.row == end_point.row {
return;
}
let start_offset = selection.start.to_offset(map, Bias::Left);
let mut pos = start_offset;
while pos > 0 {
pos -= 1;
let current_char = map.buffer_chars_at(pos).next().map(|(ch, _)| ch);
match current_char {
Some(ch) if !ch.is_whitespace() => break,
Some('\n') if pos > 0 => {
let prev_char = map.buffer_chars_at(pos - 1).next().map(|(ch, _)| ch);
if prev_char == Some('{') {
let end_pos = selection.end.to_offset(map, Bias::Left);
for (ch, offset) in map.reverse_buffer_chars_at(end_pos) {
match ch {
'\n' => {
selection.end = offset.to_display_point(map);
break;
}
ch if !ch.is_whitespace() => break,
_ => continue,
}
}
}
break;
}
_ => continue,
}
}
}
/// Returns a range that surrounds the word `relative_to` is in.
///
/// If `relative_to` is at the start of a word, return the word.
@ -1333,12 +1379,24 @@ fn surrounding_markers(
}
if !around && search_across_lines {
// Handle trailing newline after opening
if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
if ch == '\n' {
opening.end = range.end
opening.end = range.end;
// After newline, skip leading whitespace
let mut chars = movement::chars_after(map, opening.end).peekable();
while let Some((ch, range)) = chars.peek() {
if !ch.is_whitespace() {
break;
}
opening.end = range.end;
chars.next();
}
}
}
// Handle leading whitespace before closing
let mut last_newline_end = None;
for (ch, range) in movement::chars_before(map, closing.start) {
if !ch.is_whitespace() {
@ -1687,60 +1745,46 @@ mod test {
#[gpui::test]
async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let mut cx = VimTestContext::new(cx, true).await;
cx.set_shared_state(indoc! {
"func empty(a string) bool {
if a == \"\" {
return true
}
ˇreturn false
}"
})
.await;
cx.simulate_shared_keystrokes("v i {").await;
cx.shared_state().await.assert_eq(indoc! {"
func empty(a string) bool {
« if a == \"\" {
return true
}
return false
ˇ»}"});
cx.set_shared_state(indoc! {
"func empty(a string) bool {
if a == \"\" {
ˇreturn true
}
return false
}"
})
.await;
cx.simulate_shared_keystrokes("v i {").await;
cx.shared_state().await.assert_eq(indoc! {"
func empty(a string) bool {
if a == \"\" {
« return true
ˇ» }
return false
}"});
cx.set_state(
indoc! {
"func empty(a string) bool {
if a == \"\" {
return true
}
ˇreturn false
}"
},
Mode::Normal,
);
cx.simulate_keystrokes("v i {");
cx.set_shared_state(indoc! {
"func empty(a string) bool {
if a == \"\" ˇ{
return true
}
return false
}"
})
.await;
cx.simulate_shared_keystrokes("v i {").await;
cx.shared_state().await.assert_eq(indoc! {"
func empty(a string) bool {
if a == \"\" {
« return true
ˇ» }
return false
}"});
cx.set_state(
indoc! {
"func empty(a string) bool {
if a == \"\" {
ˇreturn true
}
return false
}"
},
Mode::Normal,
);
cx.simulate_keystrokes("v i {");
cx.set_state(
indoc! {
"func empty(a string) bool {
if a == \"\" ˇ{
return true
}
return false
}"
},
Mode::Normal,
);
cx.simulate_keystrokes("v i {");
}
#[gpui::test]