Add UnwrapSyntaxNode action (#31421)

Remake of #8967

> Hey there,
> 
> I have started relying on this action, that I've also put into VSCode
as [an extension](https://github.com/Gregoor/soy). On some level I don't
know how people code (cope?) without it:
> 
> Release Notes:
> 
> * Added UnwrapSyntaxNode action
> 
>
https://github.com/zed-industries/zed/assets/4051932/d74c98c0-96d8-4075-9b63-cea55bea42f6
> 
> Since I had to put it into Zed anyway to make it my daily driver, I
thought I'd also check here if there's an interest in shipping it by
default (that would ofc also personally make my life better, not having
to maintain my personal fork and all).
> 
> If there is interest, I'd be happy to make any changes to make this
more mergeable. Two TODOs on my mind are:
> 
> * unwrap multiple into single (e.g. `fn(≤a≥, b)` to `fn(≤a≥)`)
> * multi-cursor
> * syntax awareness, i.e. only unwrap if it does not break syntax (I
added [a coarse version of that for my VSC
extension](https://github.com/Gregoor/soy/blob/main/src/actions/unwrap.ts#L29))
> 
> Somewhat off-topic: I was happy to see that you're
[also](https://github.com/Gregoor/soy/blob/main/src/actions/unwrap.test.ts)
using rare special chars in test code to denote cursor positions.


Release Notes:

- Added UnwrapSyntaxNode action

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
This commit is contained in:
Gregor 2025-08-07 06:52:22 +02:00 committed by GitHub
parent f1e69f6311
commit b4a441f12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 109 additions and 0 deletions

View file

@ -745,5 +745,6 @@ actions!(
UniqueLinesCaseInsensitive,
/// Removes duplicate lines (case-sensitive).
UniqueLinesCaseSensitive,
UnwrapSyntaxNode
]
);

View file

@ -14711,6 +14711,81 @@ impl Editor {
}
}
pub fn unwrap_syntax_node(
&mut self,
_: &UnwrapSyntaxNode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
let buffer = self.buffer.read(cx).snapshot(cx);
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
let edits = old_selections
.iter()
// only consider the first selection for now
.take(1)
.map(|selection| {
// Only requires two branches once if-let-chains stabilize (#53667)
let selection_range = if !selection.is_empty() {
selection.range()
} else if let Some((_, ancestor_range)) =
buffer.syntax_ancestor(selection.start..selection.end)
{
match ancestor_range {
MultiOrSingleBufferOffsetRange::Single(range) => range,
MultiOrSingleBufferOffsetRange::Multi(range) => range,
}
} else {
selection.range()
};
let mut new_range = selection_range.clone();
while let Some((_, ancestor_range)) = buffer.syntax_ancestor(new_range.clone()) {
new_range = match ancestor_range {
MultiOrSingleBufferOffsetRange::Single(range) => range,
MultiOrSingleBufferOffsetRange::Multi(range) => range,
};
if new_range.start < selection_range.start
|| new_range.end > selection_range.end
{
break;
}
}
(selection, selection_range, new_range)
})
.collect::<Vec<_>>();
self.transact(window, cx, |editor, window, cx| {
for (_, child, parent) in &edits {
let text = buffer.text_for_range(child.clone()).collect::<String>();
editor.replace_text_in_range(Some(parent.clone()), &text, window, cx);
}
editor.change_selections(
SelectionEffects::scroll(Autoscroll::fit()),
window,
cx,
|s| {
s.select(
edits
.iter()
.map(|(s, old, new)| Selection {
id: s.id,
start: new.start,
end: new.start + old.len(),
goal: SelectionGoal::None,
reversed: s.reversed,
})
.collect(),
);
},
);
});
}
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
if !EditorSettings::get_global(cx).gutter.runnables {
self.clear_tasks();

View file

@ -7969,6 +7969,38 @@ async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppConte
});
}
#[gpui::test]
async fn test_unwrap_syntax_node(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(
&r#"
use mod1::mod2::{«mod3ˇ», mod4};
"#
.unindent(),
);
cx.update_editor(|editor, window, cx| {
editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx);
});
cx.assert_editor_state(
&r#"
use mod1::mod2::«mod3ˇ»;
"#
.unindent(),
);
}
#[gpui::test]
async fn test_fold_function_bodies(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -357,6 +357,7 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_comments);
register_action(editor, window, Editor::select_larger_syntax_node);
register_action(editor, window, Editor::select_smaller_syntax_node);
register_action(editor, window, Editor::unwrap_syntax_node);
register_action(editor, window, Editor::select_enclosing_symbol);
register_action(editor, window, Editor::move_to_enclosing_bracket);
register_action(editor, window, Editor::undo_selection);