Beam Jump - Lightning Fast Vim style navigation#45387
Beam Jump - Lightning Fast Vim style navigation#45387xipeng-jin wants to merge 12 commits intozed-industries:mainfrom
Conversation
ecstatic-morse
left a comment
There was a problem hiding this comment.
I'm not part of Zed, just a user who is excited about this functionality finally landing. I tried this locally and it looks great!
I pretty much exclusively use easy motion's bidirectional mode, so it's a little disappointing to see that part missing. It doesn't work as nicely with ;, but it's faster than having to decide between forward and backward before starting a jump.
| ]; | ||
|
|
||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| pub(crate) enum BeamJumpDirection { |
There was a problem hiding this comment.
You probably want to reuse workspace::searchable::Direction for this.
| const BASE_LABEL_CHARS: &[char] = &[ | ||
| 'f', 'j', 'd', 'k', 's', 'l', 'a', 'g', 'h', 'r', 'u', 'e', 'i', 'o', 'w', 'm', 'n', 'c', 'v', | ||
| 'x', 'z', 'p', 'q', 'y', 't', 'b', | ||
| ]; |
There was a problem hiding this comment.
It would be nice to let the user configure this in case they don't use QWERTY.
| fn reverse_beam_jump_direction(direction: BeamJumpDirection) -> BeamJumpDirection { | ||
| match direction { | ||
| BeamJumpDirection::Forward => BeamJumpDirection::Backward, | ||
| BeamJumpDirection::Backward => BeamJumpDirection::Forward, | ||
| } | ||
| } |
There was a problem hiding this comment.
Already a method on workspace::searchable::Direction
| if VimSettings::get_global(cx).beam_jump { | ||
| vim.start_beam_jump(BeamJumpDirection::Forward, action.first_char, window, cx); | ||
| } |
There was a problem hiding this comment.
It's kind of strange to reuse the PushSneak actions here. It means the user can't one mapping for sneak and another for beam jump. Is there a reason you can't introduce new actions just for beam jump?
b3bcc27 to
6c9015b
Compare
|
Is anyone actively following this PR? This feature is great, and I'd really like to integrate it into Zed. |
|
maybe a noob question, is this feature going to be available outside of vim/helix mode? activating it with a shortcut when outside of vim/helix, like we do with goto line would be awesome |
|
@HeeneFZ , Yes, we are actively cooking this feature now. This feature will need more carefully review since we want it to be the most ergonomic and also be able to seamless integrated into Zed settings. Stay tuned! |
|
Hi @RO03M , thanks for asking. This is a good question. I did think the same way to make this feature outside of vim/helix mode and more globally. We can activate it with a shortcut, like |
|
Is there a demo video for this feature? I've also created a similar feature and would like to see how well it's implemented and how complete it is. If it's well-done and has great functionality, it would be great if it could be merged into Zed. it's an essential requirement for Vimer users. |
4b8466d to
e5b857b
Compare
c42f50c to
2f10283
Compare
- Remove direction state from Beam Jump session (`s` only, `S` becomes substitute-line) - Match across entire viewport, not split by cursor - Assign labels by cursor proximity (closest matches get easiest labels) - Defer global search to `;`/`,` repeat keys - Add tests for deferred global search and arbitrary-length pattern repeat
Patch Plan
- Stop “non-extending => PassThrough”; add viewport-miss auto-global
jump + no-global-match pass-through.
- Rewrite test_beam_jump_passthrough_key; add two regression tests for
auto-global + no-global-match.
Code Changes
- Removed the !can_extend_pattern_with(...) => PassThrough behavior;
non-label keys always extend the pattern.
- When extending an existing pattern (was_pattern_extension) and
viewport matches drop to 0, immediately compute a global jump:
- Forward match exists ⇒ jump forward (count=1).
- No forward matches but global matches exist ⇒ jump to topmost match
(implemented via backward jump with count = matches_before_cursor).
- No global matches ⇒ clear_pattern() + PassThrough.
- Added clear_pattern, auto_global_jump, and helpers plus
match_pattern_at to avoid rendering Beam Jump overlays during the
transition (reuses the existing Jump→Motion::BeamJumpFind path).
Tests
- Updated: (test_beam_jump_passthrough_key) now asserts viewport-miss
triggers immediate global jump + highlights cleared + pattern persists
for ;.
- Added: (test_beam_jump_auto_global_search_on_viewport_miss) covers the
user repro (viewport has ab but no abc; buffer elsewhere has abc), and
best-effort asserts the cursor is visible after the jump.
- Added:
(test_beam_jump_auto_global_no_matches_passthrough_and_clears_pattern)
asserts cancel+passthrough on zero global matches and that last_find
is restored (so repeats don’t persist a non-existent pattern).
Patch Details `beam_jump.rs` - Add pending_commit + per-session session_id; change BeamJumpState::on_typed_char to enter pending-commit on V==1 (no immediate jump) while keeping existing V==0 auto-global behavior intact. - Added `BEAM_JUMP_PENDING_COMMIT_TIMEOUT` (default Duration::from_millis(700)). - Fixed “no global matches” on pattern extension to cancel + clear pattern + consume key (no Vim action runs) - Removed the Beam Jump “pass-through keystroke” path entirely so vim.rs can’t dispatch the typed key on cancel `vim.rs` - Render pending-commit as a single hollow highlight with label `;`, treat `;` as “commit unique” only when `pending_commit && V==1 && pattern_len>=2`, otherwise keep `;`/`,` as global navigation. - Schedule a 100ms timer via cx.background_executor().timer(...) that commits the pending jump if (session_id, pending_commit_id) still matches; reuse apply_beam_jump_jump for all jump paths to keep visuals/scrolling consistent. `test.rs` - Add pending-commit tests + assert `;` is never a label in multi-candidate mode. - Updated regression test to assert the last key is consumed (no cursor move) and ; repeat uses the prior last_find (pattern cleared)
Patch Plan - Update viewport candidate generation in `scan_first_char` to drop matches where start < cursor < end. - Update incremental refinement in `extend_matches` to drop any candidate whose extended end would cross the cursor. Tests Added - test_beam_jump_excludes_cross_cursor_match_from_pending_commit ensures a would-be pending-commit target crossing the cursor is not rendered/labelled. - test_beam_jump_extension_drops_cross_cursor_match ensures extension removes the cross-cursor candidate while keeping the valid one.
**What Changed** - `Editor::set_beam_jump_highlights` now checks whether incoming highlights are already sorted by `range.start` and only calls `sort_by_key` when they aren’t. **Call Path + Ordering Invariant** - Beam Jump typing updates: `Vim::beam_jump_input` → build highlights from `state.matches` → `editor.set_beam_jump_highlights(...)` (`crates/vim/src/vim.rs:1697`). - `BeamJumpState.matches` is already in increasing `start` order because it’s built by scanning left-to-right (`scan_first_char`, `crates/vim/src/beam_jump.rs:302`) and later filtered in-place without reordering (`extend_matches`, `crates/vim/src/beam_jump.rs:333`). - Other callsites: `set_beam_jump_highlights` is only called from `crates/vim/src/vim.rs` in this tree. **Why This Preserves Correctness** - Sorted inputs (Beam Jump steady-state) preserve identical ordering; we just skip redundant work. - Unsorted inputs remain safe: we still sort when the monotonicity check fails, preserving the sorted-by-start invariant required by `beam_jump_highlights_in_range`’s `partition_point` logic. **Complexity (M = number of highlights)** - Before: always `O(M log M)` per keystroke (unconditional sort). - After: `O(M)` per keystroke for already-sorted input (cheap adjacent scan), with `O(M log M)` only when the input is actually unsorted.
**What Changed** - Store Beam Jump highlights as `Arc<Vec<BeamJumpHighlight>>` in `Editor`, so the layout path can `clone()` the `Arc` (no allocation) and then slice it for the visible range. - `layout_beam_jump_cursors` now range-filters and iterates over the borrowed slice from the cloned `Arc`, removing the per-frame `to_vec()` allocation. **Why visuals are identical** - The visible subset is computed with the same partition_point predicates as `Editor::beam_jump_highlights_in_range`, and iteration order is unchanged; cursor/label layout code is otherwise untouched. **Allocations eliminated** - Removes the per-layout `Vec` allocation + element clones from layout_beam_jump_cursors; now it’s `Arc clone + slice` iteration.
**Fix** - Inserted the de-overlap step in `BeamJumpState::push_pattern_char` immediately after `extend_matches`, so the filtered `self.matches` is what drives highlights, label assignment, and `start -> (direction, count)` mapping (`crates/vim/src/beam_jump.rs:191`). - Implemented `retain_non_overlapping_matches_in_start_order` as an in-place linear `retain` filter using `BeamJumpMatch.end` as the “real end” (`crates/vim/src/beam_jump.rs:364`); this is already the true match end because `extend_matches` maintains it as the pattern grows. **Patch** - call `self.retain_non_overlapping_matches_in_start_order()` after `extend_matches`. - added `retain_non_overlapping_matches_in_start_order` (keeps `m` iff `m.start >= last_kept_end`, updates `last_kept_end = m.end`). - added `test_beam_jump_filters_overlapping_viewport_matches`. **Regression / Evidence** - New test: `crates/vim/src/test.rs:1780` (`aaaaa`, pattern `aa`) asserts only non-overlapping viewport candidates exist (starts 1 and 3), and selecting either label jumps correctly. - Before: viewport candidates included overlapping starts (1,2,3), but the last labeled match could be unreachable because `beam_jump_find_forward` skips overlaps via `search_start = match_end` (`crates/vim/src/motion.rs:2982`). - After: viewport candidates/labels match the reachable counted-search set; no “displayed but unreachable” labels.
matches - **What was wrong:** With viewport candidates now de-overlapped, `BeamJumpState::direction_and_count_for_start` (`crates/vim/src/beam_jump.rs:539`) can produce a `(Backward, count)` that assumes non-overlapping indexing, but `beam_jump_find_backward` was still *counting overlapping matches* via `search_end = last_char_start` (`crates/vim/src/motion.rs:3087` in the pre-fix code path). This makes label jumps land on a different (hidden) overlapping match. - **Repro test added:** `test_beam_jump_filters_overlapping_viewport_matches_backward` (`crates/vim/src/test.rs:1870`) with `"aaaaˇa"` + pattern `"aa"`. Before the fix, selecting the label for the first visible match incorrectly jumped to `aˇaaaa` instead of `ˇaaaaa`. - **Fix:** In `beam_jump_find_backward`, when `search_range.is_some()` (viewport-scoped Beam Jump jumps), count matches using **non-overlapping enumeration** by scanning forward from `range_start` to `search_end`, advancing `offset = match_end`, and selecting the `times`th-from-last match (`crates/vim/src/motion.rs:3064`). The old overlapping-counting logic remains for `search_range == None`.
**What Changed** - Store the pending-commit timer Task on Vim and drop/replace it on reschedule/clear so fast typing doesn’t accumulate detached timers (while keeping the existing id-guard correctness checks). - Old behavior: `Vim::schedule_beam_jump_pending_commit_timer` spawned and `.detach()` a task per “continue”. - New behavior: the task is stored in `Vim::beam_jump_pending_commit_task` and is cleared/replaced before scheduling, ensuring only one pending-commit timer task is live at a time. - Correctness guards unchanged: `(session_id, pending_commit_id)` checks remain in `Vim::commit_beam_jump_pending_commit`.
- Remove pending-commit debounce and any auto-global navigation while typing - Treat `;`/`,` as pattern characters during Beam Jump; `Enter` is the only navigation trigger - `Enter` cancels on short patterns / multiple viewport matches - commits unique viewport match or runs global forward find-with-wrap when V==0 - Make viewport capture strict: if visible rows are unavailable, use an empty viewport (sticky V==0) - Add 1.5s idle-cancel timer for active sessions with `pattern_len >= 1 && V == 0` (coalesced task, guarded by session/id) - Preserve RepeatFind semantics/state: persist pattern on successful jumps, restore prior repeat state on cancel/no-match - Track overlapping candidates separately from canonical matches to avoid losing matches during extension - Update/expand vim tests to cover the new key handling, label rules, wrap search, and timer behavior
- Route both interceptor/observer through one handler and harden the activation-key guard to prevent drift.
0967a3d to
15862aa
Compare
Closes #14801
Closes #4930
Summary
Beam Jump is a viewport-first, directionless “type-to-target” jump mode for Zed’s Vim mode that supports arbitrary-length patterns with low-noise visuals and no surprising navigation while typing.
How to use
Press
sto enter Beam Jump (cursor stays put; mode is directionless).Type a pattern (any length):
len == 1: show hollow outlines for eligible 1-char matches in the viewport (no labels, no jump).
len ≥ 2:
V > 1(multiple viewport matches): show hollow outlines + 1 or 2 char labels; type a label to jump.V == 1: show a single outline (no label); pressEnterto commit the jump.V == 0: press Enter to run a global forward find with wrap (the only global navigation trigger).Cancel anytime with
Esc/Backspace/Tab(consumed; no Vim action pass through).Key invariant / guarantees
len ≥ 2, viewport candidates are non-overlapping (canonical set used for labels + jumps).;/,are pattern characters while active and are never label keys.;/,outside Beam Jump) behavior.Safety / UX
pattern_len ≥ 1and viewport has zero matches, an idle-cancel timer (1.5s) cleans up the session.Enteratlen ≥ 2still performs the global forward find (wrap).Release Notes
s): arbitrary-length pattern input with viewport-first matching and minimal visual overlays.Enterto jump (no auto-jump while typing).