fix(terminal): keep the paste mark on text held across the prompt gap (#660)

`TerminalView::paste` marks the line pasted only on the branch that reaches
the editor. The other branch hands the clipboard text to `write_gap_text`,
and while the prompt is still on its way that text is *held* rather than
written: `GapHold` keeps it out of the PTY and the editor prepends the whole
net when it takes over (`Render`, and the two `engage` sites on submit and
handoff). Provenance was lost on that route, so a paste made in the gap --
paste, wait for the prompt, Enter, which is how typing ahead works -- came
out the far side looking typed and was submitted raw. A fish user with
`abbr -a l 'ls -la'` pasting `l /tmp` there got `ls -la /tmp`: exactly the
paste contract `insert_pasted` was added to hold.

The hold now carries the mark with the text it is holding, and hands it to
the editor with the net. `release` and `engage` clear it along with the net,
so it is scoped to one gap the way `CmdEditor::clear` scopes the editor's to
one line, and a passthrough marks nothing -- those bytes went straight to
the shell.

The three `engage` + `prepend_str` sites become one `engage_hold_into_editor`
so the mark cannot be dropped at one of them later.
This commit is contained in:
l0ng-ai
2026-09-07 22:13:51 +08:00
parent a689c6cb72
commit a7c7e63f42
3 changed files with 119 additions and 13 deletions
+22
View File
@@ -106,6 +106,16 @@ impl CmdEditor {
self.pasted
}
/// Prepend text the gap hold collected, and remember that some of it came
/// off the clipboard. The counterpart to [`insert_pasted`](Self::insert_pasted)
/// for the one route into this buffer that does not go through the editor:
/// a paste made before the prompt arrived is held outside it and prepended
/// when the editor takes over.
pub fn prepend_pasted(&mut self, s: &str) {
self.pasted = true;
self.prepend_str(s);
}
pub fn prepend_str(&mut self, s: &str) {
if s.is_empty() {
return;
@@ -889,5 +899,17 @@ mod tests {
assert!(!e.pasted());
e.insert_str("j build");
assert!(!e.pasted());
// Text pasted before the prompt arrived is held outside this buffer
// and prepended when the editor takes over; it has to bring the mark
// with it, or the gap would be a way around `insert_pasted`.
e.clear();
e.insert_str(" /tmp");
e.prepend_pasted("l");
assert_eq!(e.text(), "l /tmp");
assert!(e.pasted());
e.clear();
e.prepend_str("ls");
assert!(!e.pasted(), "typed gap text stays typed");
}
}
+63
View File
@@ -17,6 +17,7 @@ pub struct GapHold {
net: String,
bytes: Vec<u8>,
epoch: u64,
pasted: bool,
}
impl GapHold {
@@ -28,6 +29,28 @@ impl GapHold {
self.hold(bytes, |net| net.push_str(s))
}
/// [`hold_text`](Self::hold_text) for text that came off the clipboard
/// rather than the keyboard.
///
/// The provenance has to ride along with the held text: a paste made while
/// the prompt was still on its way lands here, not in the editor, and the
/// editor takes the whole net over when the gap ends. Without the mark
/// that text would arrive looking typed and be submitted as typed (#660),
/// which is exactly what `CmdEditor::insert_pasted` exists to prevent.
pub fn hold_pasted_text(&mut self, s: &str, bytes: &[u8]) -> Verdict {
let verdict = self.hold_text(s, bytes);
if matches!(verdict, Verdict::Held(_)) {
self.pasted = true;
}
verdict
}
/// Whether any of the text held for the editor came off the clipboard.
/// Read it before [`engage`](Self::engage), which clears it with the net.
pub fn pasted(&self) -> bool {
self.pasted
}
pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict {
self.hold(bytes, |net| {
net.pop();
@@ -53,6 +76,7 @@ impl GapHold {
pub fn release(&mut self) -> Option<(String, Vec<u8>)> {
let held = matches!(self.state, State::Holding);
self.state = State::Passthrough;
self.pasted = false;
held.then(|| {
(
std::mem::take(&mut self.net),
@@ -72,6 +96,7 @@ impl GapHold {
pub fn engage(&mut self) -> Option<String> {
self.state = State::Idle;
self.bytes.clear();
self.pasted = false;
let net = std::mem::take(&mut self.net);
(!net.is_empty()).then_some(net)
}
@@ -90,6 +115,44 @@ mod tests {
assert_eq!(h.engage(), None);
}
#[test]
fn a_paste_held_in_the_gap_reaches_the_editor_marked() {
// Pasting while the previous command is still finishing puts the
// clipboard text here rather than in the editor. It must not arrive
// looking typed: the submit path would then hand it to the shell's
// binding table (#660).
let mut h = GapHold::new();
assert!(!h.pasted(), "a fresh hold carries nothing pasted");
assert!(matches!(
h.hold_text("cat ", b"cat "),
Verdict::Held(Some(_))
));
assert!(!h.pasted());
assert!(matches!(
h.hold_pasted_text("/tmp/x", b"/tmp/x"),
Verdict::Held(None)
));
assert!(h.pasted(), "the whole net is pasted once any of it is");
assert_eq!(h.engage(), Some("cat /tmp/x".to_string()));
assert!(!h.pasted(), "engage hands the mark over with the net");
// A dump to the PTY takes the mark with it too: what the editor never
// receives cannot be submitted from it.
let mut h = GapHold::new();
h.hold_pasted_text("ls", b"ls");
assert!(h.pasted());
assert_eq!(h.release(), Some(("ls".to_string(), b"ls".to_vec())));
assert!(!h.pasted());
// Past the window the hold is a passthrough, so there is nothing to
// mark -- the bytes went straight to the shell.
assert!(matches!(
h.hold_pasted_text("x", b"x"),
Verdict::Passthrough
));
assert!(!h.pasted());
}
#[test]
fn timeout_dumps_typed_bytes_once_and_goes_passthrough() {
let mut h = GapHold::new();
+34 -13
View File
@@ -2835,7 +2835,7 @@ impl TerminalView {
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
self.write_gap_text(&text, paste_bytes(&text, bracketed), cx);
self.write_gap_text(&text, paste_bytes(&text, bracketed), true, cx);
cx.notify();
}
@@ -4017,14 +4017,21 @@ impl TerminalView {
self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt()
}
fn write_gap_text(&mut self, text: &str, bytes: Vec<u8>, cx: &mut Context<Self>) {
/// `pasted` says the text came off the clipboard rather than the keyboard,
/// so that a paste the hold keeps for the editor still reaches it marked.
fn write_gap_text(&mut self, text: &str, bytes: Vec<u8>, pasted: bool, cx: &mut Context<Self>) {
if self.shell_owns_prompt() {
self.release_hold();
self.terminal.write(bytes);
return;
}
if self.gap_holdable() && !text.chars().any(char::is_control) {
match self.hold.hold_text(text, &bytes) {
let held = if pasted {
self.hold.hold_pasted_text(text, &bytes)
} else {
self.hold.hold_text(text, &bytes)
};
match held {
Verdict::Held(arm) => {
if let Some(epoch) = arm {
self.arm_hold_timer(epoch, cx);
@@ -4040,6 +4047,26 @@ impl TerminalView {
self.observe_typeahead(RawInput::Text(text));
}
/// Move whatever the gap hold collected into the editor's buffer, keeping
/// the paste mark with it.
///
/// The hold is the one route into that buffer that does not run through
/// the editor: text arriving before the prompt does is kept out here and
/// prepended when the editor takes over. A paste that lost its provenance
/// on the way would be submitted as typed (#660) — see
/// [`CmdEditor::prepend_pasted`].
fn engage_hold_into_editor(&mut self) {
let pasted = self.hold.pasted();
let Some(net) = self.hold.engage() else {
return;
};
if pasted {
self.cmd.prepend_pasted(&net);
} else {
self.cmd.prepend_str(&net);
}
}
fn release_hold(&mut self) {
if let Some((net, bytes)) = self.hold.release() {
self.terminal.write(bytes);
@@ -4112,9 +4139,7 @@ impl TerminalView {
if self.terminal.exited || !self.accepts_input(cx) {
return;
}
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
self.engage_hold_into_editor();
let line = self.cmd.text();
if !line.trim().is_empty() {
let cwd = self.cwd();
@@ -4385,9 +4410,7 @@ impl TerminalView {
if !self.accepts_input(cx) {
return;
}
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
self.engage_hold_into_editor();
let line = self.cmd.text();
if line.contains('\n') {
cx.notify();
@@ -4878,7 +4901,7 @@ impl TerminalView {
cx.notify();
return;
}
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
self.write_gap_text(text, text.as_bytes().to_vec(), false, cx);
self.cursor_visible = true;
cx.notify();
}
@@ -6388,9 +6411,7 @@ impl Render for TerminalView {
}
self.typeahead.drain();
} else if self.input_active() {
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
self.engage_hold_into_editor();
if self.terminal.zle_reading() {
self.flush_typeahead();
}