mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user