Add optional relative line jumps to go-to-line action (#46932)

Kalaay created

Implements relative line jumping, a common feature i missed from
Vim/Neovim where you can jump to lines relative to your current cursor
position.

- New `{"relative": true}` flag for `go_to_line::Toggle` action 
- Supports both `-5` (5 lines up) and `b5` (backward 5 lines) syntax
- Full lines only (no column support in relative mode, wasnt sure if
that would be necessary)
- Unbound by default - users can add e.g. `"ctrl-j":
["go_to_line::Toggle", {"relative": true}]` to their keymap

Example usage:
- `5` → jump 5 lines down
- `-3` or `b3` → jump 3 lines up  
- `0` → stay on current line


[Screencast_20260115_191312.webm](https://github.com/user-attachments/assets/395c0e7b-8ac1-48c8-a39e-5ade4f9206ec)

Release Notes:

- Added relative line jump support to go-to-line action via
`+/-/f/b/F/B`

Change summary

crates/go_to_line/src/go_to_line.rs | 68 ++++++++++++++++++++++++++++--
1 file changed, 62 insertions(+), 6 deletions(-)

Detailed changes

crates/go_to_line/src/go_to_line.rs 🔗

@@ -26,6 +26,7 @@ pub struct GoToLine {
     active_editor: Entity<Editor>,
     current_text: SharedString,
     prev_scroll_position: Option<gpui::Point<ScrollOffset>>,
+    current_line: u32,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -143,6 +144,7 @@ impl GoToLine {
             active_editor,
             current_text: current_text.into(),
             prev_scroll_position: Some(scroll_position),
+            current_line: line,
             _subscriptions: vec![line_editor_change, cx.on_release_in(window, Self::release)],
         }
     }
@@ -210,7 +212,17 @@ impl GoToLine {
         snapshot: &MultiBufferSnapshot,
         cx: &Context<Editor>,
     ) -> Option<Anchor> {
-        let (query_row, query_char) = self.line_and_char_from_query(cx)?;
+        let (query_row, query_char) = if let Some(offset) = self.relative_line_from_query(cx) {
+            let target = if offset >= 0 {
+                self.current_line.saturating_add(offset as u32)
+            } else {
+                self.current_line.saturating_sub(offset.unsigned_abs())
+            };
+            (target, None)
+        } else {
+            self.line_and_char_from_query(cx)?
+        };
+
         let row = query_row.saturating_sub(1);
         let character = query_char.unwrap_or(0).saturating_sub(1);
 
@@ -241,6 +253,41 @@ impl GoToLine {
         Some(snapshot.anchor_before(snapshot.clip_offset(end_offset, Bias::Left)))
     }
 
+    fn relative_line_from_query(&self, cx: &App) -> Option<i32> {
+        let input = self.line_editor.read(cx).text(cx);
+        let trimmed = input.trim();
+
+        let mut last_direction_char: Option<char> = None;
+        let mut number_start_index = 0;
+
+        for (i, c) in trimmed.char_indices() {
+            match c {
+                '+' | 'f' | 'F' | '-' | 'b' | 'B' => {
+                    last_direction_char = Some(c);
+                    number_start_index = i + c.len_utf8();
+                }
+                _ => break,
+            }
+        }
+
+        let direction = last_direction_char?;
+
+        let number_part = &trimmed[number_start_index..];
+        let line_part = number_part
+            .split(FILE_ROW_COLUMN_DELIMITER)
+            .next()
+            .unwrap_or(number_part)
+            .trim();
+
+        let value = line_part.parse::<u32>().ok()?;
+
+        match direction {
+            '+' | 'f' | 'F' => Some(value as i32),
+            '-' | 'b' | 'B' => Some(-(value as i32)),
+            _ => None,
+        }
+    }
+
     fn line_and_char_from_query(&self, cx: &App) -> Option<(u32, Option<u32>)> {
         let input = self.line_editor.read(cx).text(cx);
         let mut components = input
@@ -279,12 +326,21 @@ impl GoToLine {
 
 impl Render for GoToLine {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let help_text = match self.line_and_char_from_query(cx) {
-            Some((line, Some(character))) => {
-                format!("Go to line {line}, character {character}").into()
+        let help_text = if let Some(offset) = self.relative_line_from_query(cx) {
+            let target_line = if offset >= 0 {
+                self.current_line.saturating_add(offset as u32)
+            } else {
+                self.current_line.saturating_sub(offset.unsigned_abs())
+            };
+            format!("Go to line {target_line} ({offset:+} from current)").into()
+        } else {
+            match self.line_and_char_from_query(cx) {
+                Some((line, Some(character))) => {
+                    format!("Go to line {line}, character {character}").into()
+                }
+                Some((line, None)) => format!("Go to line {line}").into(),
+                None => self.current_text.clone(),
             }
-            Some((line, None)) => format!("Go to line {line}").into(),
-            None => self.current_text.clone(),
         };
 
         v_flex()