editor: Add actions to move between comment paragraphs (#58353)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
Congratsbot / congrats (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions

Jump the caret straight to the next/previous block of prose comments —
like paragraph motion, but for comments.

The motivating workflow: reflowing a file's comments with `editor:
rewrap`. Today that means reaching for the mouse to click into each
comment block scattered through the file, or using other cursor motions
and undershooting or overshooting the target. With these two actions you
can step from one comment paragraph to the next, `rewrap`, and repeat —
reflowing every comment in a file without ever touching the mouse. It's
also handy for skimming a heavily-documented file: hop from doc comment
to doc comment without manually scrolling past the code in between.

Adds two editor actions:

- `editor::MoveToNextCommentParagraph`
- `editor::MoveToPreviousCommentParagraph`

Both move the caret to the first non-whitespace character of the
next/previous *comment paragraph*. They have no default keybinding and
are available from the command palette ("editor: move to next/previous
comment paragraph").

### What counts as a comment paragraph

A comment paragraph is a run of consecutive comment lines. A line is a
comment line when its **first non-whitespace character is in a `comment`
syntax scope** and the line contains prose (at least one alphanumeric
character). This is determined from the syntax tree
(`language_scope_at(...).override_name()`), the same mechanism `rewrap`
and comment folding already use, so it behaves correctly without
per-language string matching:

- **End-of-line comments preceded by code are ignored** — on `let x = 1;
// note` the first non-whitespace character is code, not a comment, so
the line is not a paragraph line.
- **`//` inside a string literal is ignored** — its scope is `string`,
not `comment`.
- **Blank/divider comment lines separate paragraphs** — a bare `//` or
`// -----` (no prose) acts as a separator, so you can hop between
paragraphs *within* one comment block as well as across blocks.

Both directions always move to a paragraph *other* than the one the
caret is in: when the caret is inside a paragraph, the whole current
paragraph is skipped, so `Prev` lands on the previous paragraph's start
rather than the current paragraph's own start.

### On the autoscroll

These two actions scroll the destination near the top of the viewport
(`Autoscroll::top_relative`) rather than using the default `Fit`
strategy that sibling motions use. This is deliberate and specific to
the feature: you are jumping to the **start** of a comment paragraph
that extends *downward*, so biasing the destination toward the top keeps
the whole paragraph visible after the jump. This matters for the rewrap
workflow above — you want to see the full comment you are about to
reflow, and the reflow itself changes the paragraph's line count. With
the default `Fit`, repeated forward jumps creep the caret to the bottom
edge and leave long paragraphs cut off below the fold — the opposite of
what this motion is for. Happy to revisit the exact strategy/offset if
you'd prefer consistency with the other motions.

### Tests

Two tests in `editor_tests.rs` (using a real grammar + comment override
query):

- `test_move_to_next_and_previous_comment_paragraph` — full
forward/backward round trip, covering blank comment-line separators,
code separators, trailing comments, and the no-more-paragraphs stop.
- `test_move_to_previous_comment_paragraph_skips_current_paragraph` —
`Prev` from mid-paragraph skips to the previous paragraph, and stays put
when there is no previous paragraph.

---

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments — N/A, no unsafe
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Added `editor::MoveToNextCommentParagraph` and
`editor::MoveToPreviousCommentParagraph` actions to move the caret
between comment paragraphs
This commit is contained in:
Todd L Smith 2026-07-09 00:49:09 -05:00 committed by GitHub
parent f281770034
commit 57261fef89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 392 additions and 0 deletions

View file

@ -669,10 +669,14 @@ actions!(
MoveToEnd,
/// Moves cursor to the end of the paragraph.
MoveToEndOfParagraph,
/// Moves cursor to the start of the next comment paragraph.
MoveToNextCommentParagraph,
/// Moves cursor to the end of the next subword.
MoveToNextSubwordEnd,
/// Moves cursor to the end of the next word.
MoveToNextWordEnd,
/// Moves cursor to the start of the previous comment paragraph.
MoveToPreviousCommentParagraph,
/// Moves cursor to the start of the previous subword.
MoveToPreviousSubwordStart,
/// Moves cursor to the start of the previous word.

View file

@ -2898,6 +2898,237 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext)
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, |_| {});

View file

@ -312,6 +312,8 @@ impl EditorElement {
register_action(editor, window, Editor::move_to_end_of_line);
register_action(editor, window, Editor::move_to_start_of_paragraph);
register_action(editor, window, Editor::move_to_end_of_paragraph);
register_action(editor, window, Editor::move_to_next_comment_paragraph);
register_action(editor, window, Editor::move_to_previous_comment_paragraph);
register_action(editor, window, Editor::move_to_beginning);
register_action(editor, window, Editor::move_to_end);
register_action(editor, window, Editor::move_to_start_of_excerpt);

View file

@ -582,6 +582,99 @@ pub fn end_of_paragraph(
map.max_point()
}
/// Returns whether `row` is part of a comment paragraph: a line whose first
/// non-whitespace character lies within a comment scope and which contains at
/// least one alphanumeric character.
///
/// This intentionally excludes:
/// - blank lines and code lines,
/// - end-of-line comments preceded by code (the first non-whitespace character
/// is then code, not a comment),
/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
/// alphanumeric content), which act as paragraph separators.
fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
let buffer_row = MultiBufferRow(row);
if snapshot.is_line_blank(buffer_row) {
return false;
}
let indent_len = snapshot.indent_size_for_line(buffer_row).len;
let indent_end = Point::new(row, indent_len);
let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
matches!(
scope.override_name(),
Some("comment") | Some("comment.inclusive")
)
});
if !in_comment {
return false;
}
let line_end = Point::new(row, snapshot.line_len(buffer_row));
snapshot
.text_for_range(indent_end..line_end)
.flat_map(|chunk| chunk.chars())
.any(|c| c.is_alphanumeric())
}
/// Returns the position of the first non-whitespace character of the next or
/// previous comment paragraph, relative to `from`.
///
/// A comment paragraph is a run of consecutive comment lines (see
/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
/// lines, and blank/divider comment lines. If no such paragraph exists in the
/// requested direction, `from` is returned unchanged.
///
/// Both directions always move to a *different* paragraph than the one the
/// caret is in: when the caret is inside a comment paragraph, the entire
/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
/// start rather than the current paragraph's own start.
pub fn comment_paragraph(
map: &DisplaySnapshot,
from: DisplayPoint,
direction: Direction,
) -> DisplayPoint {
let snapshot = map.buffer_snapshot();
let from_point = from.to_point(map);
let max_row = snapshot.max_row().0;
let is_paragraph_start = |row: u32| {
is_comment_paragraph_line(snapshot, row)
&& (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
};
let paragraph_start_point =
|row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
let target = match direction {
Direction::Next => (from_point.row..=max_row).find_map(|row| {
let point = paragraph_start_point(row);
(point > from_point && is_paragraph_start(row)).then_some(point)
}),
Direction::Prev => {
// If the caret is within a comment paragraph, skip over the whole
// current paragraph so we land on the *previous* paragraph rather
// than stopping at the current paragraph's own start.
let mut boundary_row = from_point.row;
if is_comment_paragraph_line(snapshot, boundary_row) {
while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
boundary_row -= 1;
}
(0..boundary_row)
.rev()
.find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
} else {
(0..=from_point.row).rev().find_map(|row| {
let point = paragraph_start_point(row);
(point < from_point && is_paragraph_start(row)).then_some(point)
})
}
}
};
match target {
Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
None => from,
}
}
pub fn start_of_excerpt(
map: &DisplaySnapshot,
display_point: DisplayPoint,

View file

@ -608,6 +608,68 @@ impl Editor {
})
}
pub fn move_to_next_comment_paragraph(
&mut self,
_: &MoveToNextCommentParagraph,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
// Keep the destination paragraph near the top of the viewport so the
// whole paragraph below the caret stays visible after a jump.
self.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
selection.collapse_to(
movement::comment_paragraph(
map,
selection.head(),
workspace::searchable::Direction::Next,
),
SelectionGoal::None,
)
});
},
)
}
pub fn move_to_previous_comment_paragraph(
&mut self,
_: &MoveToPreviousCommentParagraph,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
// Keep the destination paragraph near the top of the viewport so the
// whole paragraph below the caret stays visible after a jump.
self.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
selection.collapse_to(
movement::comment_paragraph(
map,
selection.head(),
workspace::searchable::Direction::Prev,
),
SelectionGoal::None,
)
});
},
)
}
pub fn select_to_start_of_paragraph(
&mut self,
_: &SelectToStartOfParagraph,