fix: route normal-mode cuts through clipboard pipeline

This commit is contained in:
2026-10-17 00:46:22 +02:00
parent b9f29d258c
commit d8be35e3bd
7 changed files with 103 additions and 23 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
- [x] syntax highlighting for Honey (honeylang)
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`.
- [ ] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy.
- [x] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy.
- [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+1 -1
View File
@@ -60,7 +60,7 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. Retention checks cover typing/undo/redo in editable diffs, column/newline/Unicode remapping, overlapping-token removal, and session polling with delayed/stale server replies. Live PTY smoke holds semantic replies while typing and undoing/redoing, checking that untouched colored text is not repainted in the fallback color.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard. Normal `x`/Delete regressions cover Unicode graphemes, remapped actions, multiple carets with empty rows, characterwise paste, undo/redo, dot-repeat publication and failure atomicity, and unchanged Insert Delete behavior. Live PTY smoke cuts a combining grapheme, repeats over a ZWJ emoji, pastes, undoes/redoes, and checks exact saved bytes using Separate mode.
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
+3 -1
View File
@@ -33,7 +33,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal, Visual | Ctrl+u/Ctrl+d scroll up/down without moving carets or selections; half a viewport by default, configurable with `config.scroll_step_lines`; counts multiply the distance |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo |
| Normal | `x`/Delete cut the hovered grapheme at every caret; `u` undo; `U` (Shift+u) redo |
| Normal, Visual | `:` enters command mode; Visual selections remain available to selection-aware commands |
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
@@ -144,6 +144,8 @@ Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut
## Clipboard policy
Normal `x`/Delete (`config.core.delete`) copies each hovered grapheme to the configured clipboard before removing it. Combining sequences and emoji stay intact; empty lines are unchanged, and an entirely empty cut preserves the clipboard. Dot-repeat cuts and copies the current graphemes. Insert-mode Delete and its replay do not copy.
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal/Visual `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal/Visual) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
+6 -16
View File
@@ -368,7 +368,6 @@ enum EditStart {
target: char,
selector: SelectionIntent,
},
Delete,
Join,
Paste {
after: bool,
@@ -8657,21 +8656,16 @@ impl Editor {
);
}
Action::Delete => {
if self.active.view.mode == Mode::Normal {
self.edit_normal(action, 1);
return None;
}
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) {
return None;
}
let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot());
let mask = self.deletion_mask(None);
self.delete_forward(&mask);
if let Some(before) = before {
self.record_native(
before,
EditRecipe {
start: EditStart::Delete,
steps: Vec::new(),
},
);
} else if mask.iter().any(|active| *active) {
if mask.iter().any(|active| *active) {
self.push_insert_step(InsertStep::Delete(mask));
}
}
@@ -10689,7 +10683,7 @@ impl Editor {
Action::DeleteWordBackward | Action::ChangeWordBackward => {
self.word_destination(cursor, Action::DeleteWordBackward, count)
}
Action::SubstituteCharacter => Cursor {
Action::Delete | Action::SubstituteCharacter => Cursor {
row: cursor.row,
column: cursor
.column
@@ -11537,10 +11531,6 @@ impl Editor {
self.normalize_targets();
}
}
EditStart::Delete => {
self.active.view.mode = Mode::Normal;
self.delete_forward(&[true]);
}
EditStart::Join => {
self.active.view.mode = Mode::Normal;
self.join_lines();
+2 -2
View File
@@ -25,7 +25,7 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
Ctrl+t view, Ctrl+w close, Ctrl+r refresh
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines
h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
gf follows Markdown file links/reference links and heading fragments (no LSP)
@@ -43,7 +43,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
Normal/Visual: . repeats last native edit at current targets (one undo group)
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
: opens command input with the selection preserved; Esc returns to that selection
+66
View File
@@ -42,6 +42,72 @@ fn keys(editor: &mut Editor, text: &str) {
}
}
#[test]
fn normal_x_cuts_the_hovered_grapheme_for_paste() {
for grapheme in ["b", "e\u{301}", "👩‍🚀"] {
let fixture = Fixture::new();
let mut editor = fixture.editor(&format!("a{grapheme}z\n"));
keys(&mut editor, "lx");
assert_eq!(editor.context().text, "az\n");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "P");
assert_eq!(editor.context().text, format!("a{grapheme}z\n"));
}
}
#[test]
fn normal_delete_and_remapped_action_copy_characterwise() {
for input in [Input::Delete, Input::Char('q')] {
let fixture = Fixture::new();
let mut editor = fixture.editor("abz\n");
editor
.bind("normal", "q", Some("core.delete".into()))
.unwrap();
keys(&mut editor, "yyl");
editor.handle(input);
assert_eq!(editor.context().text, "az\n");
keys(&mut editor, "uUP");
assert_eq!(editor.context().text, "abz\n");
}
}
#[test]
fn normal_x_repeat_copies_current_graphemes_and_preserves_empty_line_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\n\nxyz\n");
keys(&mut editor, "x.");
assert_eq!(editor.context().text, "c\n\nxyz\n");
keys(&mut editor, "jxjP");
assert_eq!(editor.context().text, "c\n\nbxyz\n");
}
#[test]
fn normal_x_cuts_at_every_caret_without_crossing_empty_lines() {
let fixture = Fixture::new();
let source = "a👩‍🚀z\n\nbe\u{301}y\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "x");
assert_eq!(editor.context().text, "az\n\nby\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, source);
}
#[test]
fn insert_delete_and_its_repeat_do_not_overwrite_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nxyz\n");
keys(&mut editor, "vyi");
editor.handle(Input::Delete);
editor.handle(Input::Escape);
keys(&mut editor, "j.");
assert_eq!(editor.context().text, "bc\nyz\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, "bc\nayz\n");
}
#[test]
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
let fixture = Fixture::new();
+24 -2
View File
@@ -6992,7 +6992,7 @@ mod system_clipboard {
let revision = editor.text_revision();
fixture.fail("fail-write", true);
for keys in [
"yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b",
"x", "yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b",
] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
@@ -7007,6 +7007,29 @@ mod system_clipboard {
assert_eq!(editor.context().text, "XXone\ntXwo\nthree");
}
#[test]
fn normal_x_publishes_graphemes_and_repeat_failure_is_atomic() {
let fixture = Fixture::new();
let mut editor = fixture.editor();
editor
.apply(vec![Effect::Replace("e\u{301}👩‍🚀z".into())])
.unwrap();
type_text(&mut editor, "x");
assert_eq!(editor.context().text, "👩‍🚀z");
assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes());
fixture.fail("fail-write", true);
let before = state(&editor);
type_text(&mut editor, ".");
assert_eq!(state(&editor), before);
assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes());
fixture.fail("fail-write", false);
type_text(&mut editor, ".");
assert_eq!(editor.context().text, "z");
assert_eq!(fixture.bytes("state"), "👩‍🚀".as_bytes());
type_text(&mut editor, "uUP");
assert_eq!(editor.context().text, "👩‍🚀z");
}
#[test]
fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() {
let fixture = Fixture::new();
@@ -7707,7 +7730,6 @@ mod system_clipboard {
editor.set_clipboard_mode("system").unwrap();
editor.apply(vec![Effect::Replace("plain".into())]).unwrap();
set_carets(&mut editor, &[(0, 0)], 0);
type_text(&mut editor, "x.");
type_text(&mut editor, "iX");
editor.handle(Input::Backspace);
editor.handle(Input::Delete);