Closes #51693 Helix was unable to paste from the system clipboard, ex: `vim: paste` would work but `helix: paste` would not work. Helix paste was silently requiring the content it was going to paste to have selection metadata exist, and just silently fail if it didn't, and the system clipboard doesn't have that metadata. note: this is not necessarily for parity with helix, as helix didn't seem to support this either in my testing, but rather parity with the other parts of zed, editor mode and vim mode. single-line paste: https://github.com/user-attachments/assets/c8696032-d265-4025-9c4c-a8c35dfd2529 multi-line paste: https://github.com/user-attachments/assets/4bf96033-e13d-4ec1-8a7e-8c56bbc12b94 I also added a new test verifying the behavior. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - helix: fixed helix paste not pasting from system clipboard.
513 lines
16 KiB
Rust
513 lines
16 KiB
Rust
use editor::{ToOffset, movement};
|
|
use gpui::{Action, Context, Window};
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
|
|
use crate::{Vim, state::Mode};
|
|
|
|
/// Pastes text from the specified register at the cursor position.
|
|
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
|
|
#[action(namespace = vim)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HelixPaste {
|
|
#[serde(default)]
|
|
before: bool,
|
|
}
|
|
|
|
impl Vim {
|
|
pub fn helix_paste(
|
|
&mut self,
|
|
action: &HelixPaste,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.record_current_action(cx);
|
|
self.store_visual_marks(window, cx);
|
|
let count = Vim::take_count(cx).unwrap_or(1);
|
|
// TODO: vim paste calls take_forced_motion here, but I don't know what that does
|
|
// (none of the other helix_ methods call it)
|
|
|
|
self.update_editor(cx, |vim, editor, cx| {
|
|
editor.transact(window, cx, |editor, window, cx| {
|
|
editor.set_clip_at_line_ends(false, cx);
|
|
|
|
let selected_register = vim.selected_register.take();
|
|
|
|
let Some(register) = Vim::update_globals(cx, |globals, cx| {
|
|
globals.read_register(selected_register, Some(editor), cx)
|
|
})
|
|
.filter(|reg| !reg.text.is_empty()) else {
|
|
return;
|
|
};
|
|
let text = register.text;
|
|
let clipboard_selections = register.clipboard_selections;
|
|
|
|
let display_map = editor.display_snapshot(cx);
|
|
let current_selections = editor.selections.all_adjusted_display(&display_map);
|
|
|
|
// The clipboard can have multiple selections, and there can
|
|
// be multiple selections. Helix zips them together, so the first
|
|
// clipboard entry gets pasted at the first selection, the second
|
|
// entry gets pasted at the second selection, and so on. If there
|
|
// are more clipboard selections than selections, the extra ones
|
|
// don't get pasted anywhere. If there are more selections than
|
|
// clipboard selections, the last clipboard selection gets
|
|
// pasted at all remaining selections.
|
|
|
|
let mut edits = Vec::new();
|
|
let mut new_selections = Vec::new();
|
|
let mut start_offset = 0;
|
|
|
|
let mut replacement_texts: Vec<String> = Vec::new();
|
|
|
|
for ix in 0..current_selections.len() {
|
|
let to_insert = if let Some(clip_sel) =
|
|
clipboard_selections.as_ref().and_then(|s| s.get(ix))
|
|
{
|
|
let end_offset = start_offset + clip_sel.len;
|
|
let text = text[start_offset..end_offset].to_string();
|
|
start_offset = if clip_sel.is_entire_line {
|
|
end_offset
|
|
} else {
|
|
end_offset + 1
|
|
};
|
|
text
|
|
} else if let Some(last_text) = replacement_texts.last() {
|
|
// We have more current selections than clipboard selections: repeat the last one.
|
|
last_text.to_owned()
|
|
} else {
|
|
text.to_string()
|
|
};
|
|
replacement_texts.push(to_insert);
|
|
}
|
|
|
|
let line_mode = replacement_texts.iter().any(|text| text.ends_with('\n'));
|
|
|
|
for (to_insert, sel) in replacement_texts.into_iter().zip(current_selections) {
|
|
// Helix doesn't care about the head/tail of the selection.
|
|
// Pasting before means pasting before the whole selection.
|
|
let display_point = if line_mode {
|
|
if action.before {
|
|
movement::line_beginning(&display_map, sel.start, false)
|
|
} else {
|
|
if sel.start == sel.end {
|
|
movement::right(
|
|
&display_map,
|
|
movement::line_end(&display_map, sel.end, false),
|
|
)
|
|
} else {
|
|
sel.end
|
|
}
|
|
}
|
|
} else if action.before {
|
|
sel.start
|
|
} else if sel.start == sel.end {
|
|
// In Helix, a single-point cursor is "on top" of a
|
|
// character, and pasting after means after that character.
|
|
// At line end this means the next line. But on an empty
|
|
// line there is no character, so paste at the cursor.
|
|
let right = movement::right(&display_map, sel.end);
|
|
if right.row() != sel.end.row() && sel.end.column() == 0 {
|
|
sel.end
|
|
} else {
|
|
right
|
|
}
|
|
} else {
|
|
sel.end
|
|
};
|
|
let point = display_point.to_point(&display_map);
|
|
let anchor = if action.before {
|
|
display_map.buffer_snapshot().anchor_after(point)
|
|
} else {
|
|
display_map.buffer_snapshot().anchor_before(point)
|
|
};
|
|
edits.push((point..point, to_insert.repeat(count)));
|
|
new_selections.push((anchor, to_insert.len() * count));
|
|
}
|
|
|
|
editor.edit(edits, cx);
|
|
|
|
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges(new_selections.into_iter().map(|(anchor, len)| {
|
|
let offset = anchor.to_offset(&snapshot);
|
|
if action.before {
|
|
offset.saturating_sub_usize(len)..offset
|
|
} else {
|
|
offset..(offset + len)
|
|
}
|
|
}));
|
|
})
|
|
});
|
|
});
|
|
|
|
self.switch_mode(Mode::HelixNormal, true, window, cx);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use indoc::indoc;
|
|
|
|
use gpui::ClipboardItem;
|
|
|
|
use crate::{state::Mode, test::VimTestContext};
|
|
|
|
#[gpui::test]
|
|
async fn test_system_clipboard_paste(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
cx.enable_helix();
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quiˇck brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.write_to_clipboard(ClipboardItem::new_string("clipboard".to_string()));
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quic«clipboardˇ»k brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Multiple cursors with system clipboard (no metadata) pastes
|
|
// the same text at each cursor.
|
|
cx.set_state(
|
|
indoc! {"
|
|
ˇThe quick brown
|
|
fox ˇjumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.write_to_clipboard(ClipboardItem::new_string("hi".to_string()));
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
T«hiˇ»he quick brown
|
|
fox j«hiˇ»umps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Multiple cursors on empty lines should paste on those same lines.
|
|
cx.set_state("ˇ\nˇ\nˇ\nend", Mode::HelixNormal);
|
|
cx.write_to_clipboard(ClipboardItem::new_string("X".to_string()));
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state("«Xˇ»\n«Xˇ»\n«Xˇ»\nend", Mode::HelixNormal);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_paste(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
cx.enable_helix();
|
|
cx.set_state(
|
|
indoc! {"
|
|
The «quiˇ»ck brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.simulate_keystrokes("y w p");
|
|
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick «quiˇ»brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Pasting before the selection:
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox «jumpsˇ» over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox «quiˇ»jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_point_selection_paste(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
cx.enable_helix();
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quiˇck brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.simulate_keystrokes("y");
|
|
|
|
// Pasting before the selection:
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps«cˇ» over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Pasting after the selection:
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps «cˇ»over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Pasting after the selection at the end of a line:
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps overˇ
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps over
|
|
«cˇ»the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_multi_cursor_paste(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
cx.enable_helix();
|
|
// Select two blocks of text.
|
|
cx.set_state(
|
|
indoc! {"
|
|
The «quiˇ»ck brown
|
|
fox ju«mpsˇ» over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("y");
|
|
|
|
// Only one cursor: only the first block gets pasted.
|
|
cx.set_state(
|
|
indoc! {"
|
|
ˇThe quick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
«quiˇ»The quick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Two cursors: both get pasted.
|
|
cx.set_state(
|
|
indoc! {"
|
|
ˇThe ˇquick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
«quiˇ»The «mpsˇ»quick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Three cursors: the second yanked block is duplicated.
|
|
cx.set_state(
|
|
indoc! {"
|
|
ˇThe ˇquick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
«quiˇ»The «mpsˇ»quick brown
|
|
fox jumps«mpsˇ» over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// Again with three cursors. All three should be pasted twice.
|
|
cx.set_state(
|
|
indoc! {"
|
|
ˇThe ˇquick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("2 shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
«quiquiˇ»The «mpsmpsˇ»quick brown
|
|
fox jumps«mpsmpsˇ» over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_line_mode_paste(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
cx.enable_helix();
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brow«n
|
|
ˇ»fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.simulate_keystrokes("y shift-p");
|
|
|
|
cx.assert_state(
|
|
indoc! {"
|
|
«n
|
|
ˇ»The quick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// In line mode, if we're in the middle of a line then pasting before pastes on
|
|
// the line before.
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("shift-p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
«n
|
|
ˇ»fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// In line mode, if we're in the middle of a line then pasting after pastes on
|
|
// the line after.
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumpsˇ over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps over
|
|
«n
|
|
ˇ»the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
// If we're currently at the end of a line, "the line after"
|
|
// means right after the cursor.
|
|
cx.set_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps overˇ
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps over
|
|
«n
|
|
ˇ»the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.set_state(
|
|
indoc! {"
|
|
|
|
The quick brown
|
|
fox jumps overˇ
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("x y up up p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
|
|
«fox jumps over
|
|
ˇ»The quick brown
|
|
fox jumps over
|
|
the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
|
|
cx.set_state(
|
|
indoc! {"
|
|
«The quick brown
|
|
fox jumps over
|
|
ˇ»the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
cx.simulate_keystrokes("y p p");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
The quick brown
|
|
fox jumps over
|
|
The quick brown
|
|
fox jumps over
|
|
«The quick brown
|
|
fox jumps over
|
|
ˇ»the lazy dog."},
|
|
Mode::HelixNormal,
|
|
);
|
|
}
|
|
}
|