mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
chore(terminal): drop the client-side command-mark store (#404)
Removing the Outline panel (#374 / #375) took away the only reader of the client-side command marks. The scanner kept running on every batch of PTY output, and it was the one scanner that forced the batch to be split before it reached the emulator, so it was not free. Gone: `MarkScanner`, `Marks`, `CommandMark`, `record_mark`, `Cut::Mark` and the tests that only covered them. With the cursor cut as the sole cut left, the offset sort is a no-op and goes too — `ParkedCursorScanner` already reports in ascending order — and `Cut` itself collapses into a plain `CursorCut`. Kept: `zle_tok` and `mode_tok` read the same OSC 133 bytes and are load-bearing for `zle_reading` / `shell_vi_mode`, including the deliberate live-vs-snapshot split. Daemon-side OSC 133 handling is untouched. Dropping `marks().clear()` left `clear_scrollback` with no anchored-state invalidation at all, and it never had any for the other store that needs it: kitty image placements are anchored to an absolute scrollback row, so purging the history moves every anchor and the frame paints over unrelated text or resolves past the viewport, with no redraw coming since the daemon does not replay out-of-band image frames. Clear the image store there, as the reattach path already does, and route the purge through `Term::clear_screen(ClearMode::Saved)` so a selection reaching into the discarded rows is invalidated instead of clamping onto the viewport. Closes #378
This commit is contained in:
+18
-12
@@ -9,18 +9,24 @@
|
||||
//!
|
||||
//! # Why anchor by absolute row
|
||||
//!
|
||||
//! Like a command [`mark`](crate::terminal::marks), an image's position has to
|
||||
//! survive scrolling. A kitty image is placed at the cursor cell as it stood when
|
||||
//! its command appeared in the stream; once recorded, the grid keeps scrolling
|
||||
//! under it. We store the row as an absolute index from the top of scrollback
|
||||
//! (`history_size - display_offset + cursor_line`, the exact formula
|
||||
//! [`record_mark`](crate::terminal::remote) uses) and convert back to a screen
|
||||
//! row at paint time (`anchor_row - history_size + display_offset`, the inverse
|
||||
//! conversion). Below the scrollback limit — where a pane spends most of its life —
|
||||
//! this is exact; past it the anchor drifts by the (unobservable) discard count,
|
||||
//! the same caveat marks carry, and a browser that redraws every frame corrects
|
||||
//! An image's position has to survive scrolling. A kitty image is placed at the
|
||||
//! cursor cell as it stood when its command appeared in the stream; once recorded,
|
||||
//! the grid keeps scrolling under it. We store the row as an absolute index from
|
||||
//! the top of scrollback (`history_size - display_offset + cursor_line`) and
|
||||
//! convert back to a screen row at paint time (`anchor_row - history_size +
|
||||
//! display_offset`, the inverse conversion). Below the scrollback limit — where a
|
||||
//! pane spends most of its life — this is exact; past it the anchor drifts by the
|
||||
//! (unobservable) discard count, and a browser that redraws every frame corrects
|
||||
//! it on the next transmit anyway.
|
||||
//!
|
||||
//! The anchor is read off whichever grid is active, and the alt screen has no
|
||||
//! history of its own — so an image placed there records a small absolute row
|
||||
//! that resolves against the primary grid once the app exits. A sender that
|
||||
//! deletes its own images on the way out (the normal case) is unaffected; one
|
||||
//! that dies without an `a=d` can leave a frame anchored over the primary
|
||||
//! screen. Modelling that properly wants a per-screen store rather than one
|
||||
//! keyed on the displayed grid.
|
||||
//!
|
||||
//! GPUI's sprite atlas expects **BGRA** pixels (it swaps R↔B when caching an
|
||||
//! `image` crate `RgbaImage` — see `gpui::img`), so [`decode`] does the swap once
|
||||
//! at ingest; the placed [`RenderImage`] is uploaded verbatim thereafter.
|
||||
@@ -66,7 +72,7 @@ pub struct PlacedImage {
|
||||
|
||||
/// A pane's placed images plus the retired render images awaiting atlas
|
||||
/// eviction, shared between the reader thread (writer) and the paint path
|
||||
/// (reader), exactly like [`Marks`](crate::terminal::marks::Marks).
|
||||
/// (reader).
|
||||
///
|
||||
/// `retired` is the other half of the fix for a browser that repaints at 60fps:
|
||||
/// each transmitted frame becomes a fresh [`RenderImage`] with a new atlas id,
|
||||
@@ -81,7 +87,7 @@ struct StoreInner {
|
||||
}
|
||||
|
||||
/// A pane's placed images, shared between the reader thread (writer) and the
|
||||
/// paint path (reader), exactly like [`Marks`](crate::terminal::marks::Marks).
|
||||
/// paint path (reader).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ImageStore(Arc<Mutex<StoreInner>>);
|
||||
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const MAX_MARKS: usize = 500;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CommandMark {
|
||||
pub row: i64,
|
||||
pub text: String,
|
||||
pub exit: Option<i32>,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Marks(Arc<Mutex<Vec<CommandMark>>>);
|
||||
|
||||
impl Marks {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn begin(&self, row: i64, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if marks.last().is_some_and(|m| m.row == row && !m.done) {
|
||||
if let Some(last) = marks.last_mut() {
|
||||
last.text = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
marks.push(CommandMark {
|
||||
row,
|
||||
text,
|
||||
exit: None,
|
||||
done: false,
|
||||
});
|
||||
let overflow = marks.len().saturating_sub(MAX_MARKS);
|
||||
if overflow > 0 {
|
||||
marks.drain(..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(&self, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
if !last.done {
|
||||
last.text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&self, exit: Option<i32>) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
last.done = true;
|
||||
last.exit = exit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the tests read the store back; the panel that used to has been
|
||||
/// removed, and prompt state reaches the client over the wire instead.
|
||||
#[cfg(test)]
|
||||
pub fn list(&self) -> Vec<CommandMark> {
|
||||
let Ok(marks) = self.0.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
marks
|
||||
.iter()
|
||||
.filter(|m| !m.text.trim().is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut marks) = self.0.lock() {
|
||||
marks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_done_exit(payload: &[u8]) -> Option<i32> {
|
||||
let rest = payload.strip_prefix(b"D")?;
|
||||
let rest = rest.strip_prefix(b";")?;
|
||||
std::str::from_utf8(rest).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MarkEvent {
|
||||
Prompt,
|
||||
Command(String),
|
||||
Done(Option<i32>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MarkScanner {
|
||||
state: ScanState,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
enum ScanState {
|
||||
#[default]
|
||||
Text,
|
||||
Esc,
|
||||
Osc,
|
||||
OscEsc,
|
||||
}
|
||||
|
||||
const MAX_PAYLOAD: usize = 64 * 1024;
|
||||
|
||||
impl MarkScanner {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) {
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if self.state == ScanState::Text {
|
||||
let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else {
|
||||
return;
|
||||
};
|
||||
self.state = ScanState::Esc;
|
||||
i += off + 1;
|
||||
continue;
|
||||
}
|
||||
let b = bytes[i];
|
||||
match self.state {
|
||||
ScanState::Text => unreachable!(),
|
||||
ScanState::Esc => {
|
||||
if b == b']' {
|
||||
self.state = ScanState::Osc;
|
||||
self.payload.clear();
|
||||
} else {
|
||||
self.state = if b == 0x1b {
|
||||
ScanState::Esc
|
||||
} else {
|
||||
ScanState::Text
|
||||
};
|
||||
}
|
||||
}
|
||||
ScanState::Osc => match b {
|
||||
0x07 => {
|
||||
if let Some(ev) = self.take() {
|
||||
on_mark(i + 1, ev);
|
||||
}
|
||||
self.state = ScanState::Text;
|
||||
}
|
||||
0x1b => self.state = ScanState::OscEsc,
|
||||
_ => {
|
||||
if self.payload.len() < MAX_PAYLOAD {
|
||||
self.payload.push(b);
|
||||
} else {
|
||||
self.state = ScanState::Text;
|
||||
self.payload.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
ScanState::OscEsc => {
|
||||
if b == b'\\' {
|
||||
if let Some(ev) = self.take() {
|
||||
on_mark(i + 1, ev);
|
||||
}
|
||||
self.state = ScanState::Text;
|
||||
} else {
|
||||
if self.payload.len() < MAX_PAYLOAD {
|
||||
self.payload.push(0x1b);
|
||||
self.state = ScanState::Osc;
|
||||
} else {
|
||||
self.state = ScanState::Text;
|
||||
self.payload.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn take(&mut self) -> Option<MarkEvent> {
|
||||
let payload = std::mem::take(&mut self.payload);
|
||||
let body = payload.strip_prefix(b"133;")?;
|
||||
match body.first()? {
|
||||
b'A' => Some(MarkEvent::Prompt),
|
||||
b'C' => {
|
||||
let cmd = body
|
||||
.strip_prefix(b"C;")
|
||||
.map(|c| String::from_utf8_lossy(c).into_owned())
|
||||
.unwrap_or_default();
|
||||
Some(MarkEvent::Command(cmd))
|
||||
}
|
||||
b'D' => Some(MarkEvent::Done(parse_done_exit(body))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_prompt_with_no_command_is_not_an_entry() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
assert!(
|
||||
marks.list().is_empty(),
|
||||
"an empty prompt the user walked away from isn't a command"
|
||||
);
|
||||
marks.set_text("cargo build".into());
|
||||
assert_eq!(marks.list().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_redraw_updates_in_place() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("cargo t".into());
|
||||
marks.begin(10, "cargo test".into());
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one");
|
||||
assert_eq!(got[0].text, "cargo test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_after_a_finished_command_is_a_new_entry() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("ls".into());
|
||||
marks.finish(Some(0));
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("pwd".into());
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].exit, Some(0));
|
||||
assert!(!got[1].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_are_capped_from_the_front() {
|
||||
let marks = Marks::new();
|
||||
for i in 0..(MAX_MARKS + 10) {
|
||||
marks.begin(i as i64, format!("cmd{i}"));
|
||||
marks.finish(Some(0));
|
||||
}
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), MAX_MARKS);
|
||||
assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest");
|
||||
}
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut out = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, |off, ev| out.push((off, ev)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_anywhere_yields_the_same_marks() {
|
||||
let stream: &[u8] =
|
||||
b"out\x1b\x1b[32mmore\x1b]133;C;git status\x07text\x1b]133;D;0\x1b\\tail\x1b";
|
||||
let whole = scan(&[stream]);
|
||||
assert_eq!(whole.len(), 2, "both marks found in one pass");
|
||||
|
||||
for at in 0..=stream.len() {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut got = Vec::new();
|
||||
scanner.feed(&stream[..at], |off, ev| got.push((off, ev)));
|
||||
scanner.feed(&stream[at..], |off, ev| got.push((at + off, ev)));
|
||||
assert_eq!(got, whole, "splitting at {at} changed the marks");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_marks_just_past_their_terminator() {
|
||||
let got = scan(&[b"ab\x1b]133;A\x07cd"]);
|
||||
assert_eq!(got, vec![(10, MarkEvent::Prompt)]);
|
||||
assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carries_a_mark_split_across_two_feeds() {
|
||||
let got = scan(&[b"out\x1b]13", b"3;C;cargo build\x07more"]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![(16, MarkEvent::Command("cargo build".into()))],
|
||||
"the mark is attributed to the batch its terminator lands in"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_st_terminated_marks() {
|
||||
let got = scan(&[b"\x1b]133;D;130\x1b\\"]);
|
||||
assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_osc_sequences() {
|
||||
let got = scan(&[b"\x1b]0;a title\x07\x1b]7;file://h/x\x07\x1b]133;B\x07"]);
|
||||
assert!(
|
||||
got.is_empty(),
|
||||
"titles, cwd reports and prompt-end are not command marks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_containing_semicolons_survives_intact() {
|
||||
let got = scan(&[b"\x1b]133;C;for i in a b; do echo $i; done\x07"]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![(
|
||||
39,
|
||||
MarkEvent::Command("for i in a b; do echo $i; done".into())
|
||||
)],
|
||||
"only the first two fields are structure; the rest is the command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_payload_cannot_grow_without_bound() {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut fired = 0;
|
||||
scanner.feed(b"\x1b]133;C;", |_, _| fired += 1);
|
||||
for _ in 0..40 {
|
||||
scanner.feed(&vec![b'x'; 4096], |_, _| fired += 1);
|
||||
}
|
||||
assert_eq!(fired, 0, "never terminated, so never reported");
|
||||
assert!(scanner.payload.len() <= MAX_PAYLOAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_done_payloads() {
|
||||
assert_eq!(parse_done_exit(b"D;0"), Some(0));
|
||||
assert_eq!(parse_done_exit(b"D;130"), Some(130));
|
||||
assert_eq!(parse_done_exit(b"D"), None, "done, code unknown");
|
||||
assert_eq!(parse_done_exit(b"D;aborted"), None);
|
||||
assert_eq!(parse_done_exit(b"C"), None, "not a done mark at all");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ mod hold;
|
||||
pub(crate) mod images;
|
||||
pub mod input;
|
||||
mod loopback;
|
||||
pub(crate) mod marks;
|
||||
pub mod palette;
|
||||
pub(crate) mod pane_liveness;
|
||||
pub(crate) mod parked_cursor;
|
||||
|
||||
@@ -36,10 +36,9 @@ pub enum CursorCut {
|
||||
Shown { parked: bool },
|
||||
}
|
||||
|
||||
/// Byte scanner over the pty stream, reporting cursor hide/shows in the same
|
||||
/// shape [`crate::terminal::marks::MarkScanner`] reports prompt marks: an
|
||||
/// offset one past the sequence, so the reader can advance the emulator to
|
||||
/// exactly there and act on the state the sequence left behind.
|
||||
/// Byte scanner over the pty stream, reporting cursor hide/shows as an offset
|
||||
/// one past the sequence, so the reader can advance the emulator to exactly
|
||||
/// there and act on the state the sequence left behind.
|
||||
#[derive(Default)]
|
||||
pub struct ParkedCursorScanner {
|
||||
state: State,
|
||||
|
||||
+13
-97
@@ -12,7 +12,6 @@ use alacritty_terminal::sync::FairMutex;
|
||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
|
||||
|
||||
use crate::terminal::marks::{MarkEvent, MarkScanner};
|
||||
use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
@@ -77,7 +76,6 @@ struct ReaderSignals {
|
||||
shell_vi_mode: Arc<AtomicBool>,
|
||||
auth: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>>,
|
||||
phase: Arc<Mutex<Option<SshPhase>>>,
|
||||
marks: crate::terminal::marks::Marks,
|
||||
/// Kitty-graphics images the daemon lifted out of the stream (issue #213),
|
||||
/// anchored to the grid for the paint path to blit. Shared with the reader,
|
||||
/// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land.
|
||||
@@ -176,11 +174,10 @@ pub struct RemoteTerminal {
|
||||
auto_supplied_password: bool,
|
||||
agent: Arc<Mutex<Option<CLIAgent>>>,
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
marks: crate::terminal::marks::Marks,
|
||||
/// Kitty-graphics images placed on this pane's grid (issue #213).
|
||||
/// Written by the reader thread from out-of-band `Image`/`DeleteImage`
|
||||
/// frames, read by the paint path — same shared-handle discipline as
|
||||
/// `marks`, since only the client holds the grid the anchors are relative to.
|
||||
/// frames, read by the paint path — only the client holds the grid the
|
||||
/// anchors are relative to, so the store lives here rather than in the daemon.
|
||||
images: crate::terminal::images::ImageStore,
|
||||
route: PaneRoute,
|
||||
proxy: EventProxy,
|
||||
@@ -409,7 +406,6 @@ impl RemoteTerminal {
|
||||
shell_vi_mode: self.shell_vi_mode.clone(),
|
||||
auth: self.auth_prompts.clone(),
|
||||
phase: self.ssh_phase.clone(),
|
||||
marks: self.marks.clone(),
|
||||
images: self.images.clone(),
|
||||
},
|
||||
);
|
||||
@@ -459,7 +455,6 @@ impl RemoteTerminal {
|
||||
let auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>> =
|
||||
Arc::new(Mutex::new(VecDeque::new()));
|
||||
let ssh_phase: Arc<Mutex<Option<SshPhase>>> = Arc::new(Mutex::new(None));
|
||||
let marks = crate::terminal::marks::Marks::new();
|
||||
let images = crate::terminal::images::ImageStore::new();
|
||||
|
||||
let reader_quit = Arc::new(AtomicBool::new(false));
|
||||
@@ -481,7 +476,6 @@ impl RemoteTerminal {
|
||||
shell_vi_mode: shell_vi_mode.clone(),
|
||||
auth: auth_prompts.clone(),
|
||||
phase: ssh_phase.clone(),
|
||||
marks: marks.clone(),
|
||||
images: images.clone(),
|
||||
},
|
||||
);
|
||||
@@ -508,7 +502,6 @@ impl RemoteTerminal {
|
||||
auto_supplied_password: false,
|
||||
agent,
|
||||
agent_session,
|
||||
marks,
|
||||
images,
|
||||
route: PaneRoute::Local,
|
||||
proxy,
|
||||
@@ -571,7 +564,6 @@ impl RemoteTerminal {
|
||||
shell_vi_mode,
|
||||
auth,
|
||||
phase,
|
||||
marks,
|
||||
images,
|
||||
} = signals;
|
||||
crate::core::threads::promote_to_user_interactive();
|
||||
@@ -580,7 +572,6 @@ impl RemoteTerminal {
|
||||
let mut osc = OscNotifyScanner::default();
|
||||
let mut mode_tok = OscTokenizer::new(&[b"133"]);
|
||||
let mut zle_tok = OscTokenizer::new(&[b"133"]);
|
||||
let mut mark_scan = MarkScanner::new();
|
||||
let mut cursor_scan = ParkedCursorScanner::new();
|
||||
let mut parked_cursor = ParkedCursorRepair::default();
|
||||
let mut pending: Vec<u8> = buffered;
|
||||
@@ -627,18 +618,13 @@ impl RemoteTerminal {
|
||||
macro_rules! flush_batch {
|
||||
() => {
|
||||
if !out_batch.is_empty() {
|
||||
// Both scanners report an offset one past the
|
||||
// sequence they matched, so the batch splits at
|
||||
// each of them: advance the emulator to the cut,
|
||||
// act on the state that sequence left behind,
|
||||
// carry on. In offset order, since a frame can
|
||||
// carry marks and cursor shows both.
|
||||
let mut cuts: Vec<(usize, Cut)> = Vec::new();
|
||||
mark_scan
|
||||
.feed(&out_batch, |off, ev| cuts.push((off, Cut::Mark(ev))));
|
||||
cursor_scan
|
||||
.feed(&out_batch, |off, c| cuts.push((off, Cut::Cursor(c))));
|
||||
cuts.sort_by_key(|(off, _)| *off);
|
||||
// The scanner reports an offset one past the
|
||||
// sequence it matched, in ascending order, so the
|
||||
// batch splits at each of them: advance the
|
||||
// emulator to the cut, act on the state that
|
||||
// sequence left behind, carry on.
|
||||
let mut cuts: Vec<(usize, CursorCut)> = Vec::new();
|
||||
cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c)));
|
||||
{
|
||||
let t0 = trace.then(std::time::Instant::now);
|
||||
let mut term = term.lock();
|
||||
@@ -653,12 +639,7 @@ impl RemoteTerminal {
|
||||
for (off, cut) in cuts {
|
||||
processor.advance(&mut *term, &out_batch[at..off]);
|
||||
at = off;
|
||||
match cut {
|
||||
Cut::Mark(ev) => record_mark(&term, &marks, ev),
|
||||
Cut::Cursor(vis) => {
|
||||
parked_cursor.apply(&mut term, vis);
|
||||
}
|
||||
}
|
||||
parked_cursor.apply(&mut term, cut);
|
||||
}
|
||||
processor.advance(&mut *term, &out_batch[at..]);
|
||||
}
|
||||
@@ -786,8 +767,9 @@ impl RemoteTerminal {
|
||||
// around it. Flush the pending text first so the grid
|
||||
// cursor sits where the sender drew the image, then
|
||||
// anchor the placement to that cell in scroll-stable
|
||||
// absolute-row coordinates (the same formula
|
||||
// `record_mark` uses), so it tracks scrolling.
|
||||
// absolute-row coordinates (`history_size -
|
||||
// display_offset + cursor_line`), so it tracks
|
||||
// scrolling.
|
||||
DaemonMsg::Image(frame) => {
|
||||
flush_batch!();
|
||||
if let Some(img) =
|
||||
@@ -1108,10 +1090,6 @@ impl RemoteTerminal {
|
||||
self.agent.lock().ok().and_then(|g| *g)
|
||||
}
|
||||
|
||||
pub fn marks(&self) -> crate::terminal::marks::Marks {
|
||||
self.marks.clone()
|
||||
}
|
||||
|
||||
/// The kitty-graphics image store for this pane. Cheap handle clone — the
|
||||
/// store is an `Arc<Mutex<..>>` shared with the reader thread, which places
|
||||
/// and deletes images as out-of-band frames arrive from the daemon.
|
||||
@@ -1512,27 +1490,6 @@ impl RemoteTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Something in the pty stream the reader has to act on at the byte where it
|
||||
/// appeared, rather than after the whole batch has been parsed.
|
||||
enum Cut {
|
||||
Mark(MarkEvent),
|
||||
Cursor(CursorCut),
|
||||
}
|
||||
|
||||
fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, event: MarkEvent) {
|
||||
use alacritty_terminal::grid::Dimensions as _;
|
||||
match event {
|
||||
MarkEvent::Prompt => {
|
||||
let grid = term.grid();
|
||||
let row = grid.history_size() as i64 - grid.display_offset() as i64
|
||||
+ i64::from(grid.cursor.point.line.0);
|
||||
marks.begin(row, String::new());
|
||||
}
|
||||
MarkEvent::Command(cmd) => marks.set_text(cmd),
|
||||
MarkEvent::Done(exit) => marks.finish(exit),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_not_listening(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||
@@ -3425,47 +3382,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_record_the_row_each_one_landed_on() {
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
let poll = |want: usize| {
|
||||
for _ in 0..200 {
|
||||
if term.marks().list().len() == want {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(b"\x1b]133;A\x07");
|
||||
stream.extend_from_slice(b"\x1b]133;C;echo one\x07");
|
||||
stream.extend_from_slice(b"one\r\n");
|
||||
stream.extend_from_slice(b"\x1b]133;D;0\x07");
|
||||
stream.extend_from_slice(b"\x1b]133;A\x07");
|
||||
stream.extend_from_slice(b"\x1b]133;C;false\x07");
|
||||
stream.extend_from_slice(b"\r\n");
|
||||
stream.extend_from_slice(b"\x1b]133;D;1\x07");
|
||||
DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
|
||||
assert!(poll(2), "both commands recorded");
|
||||
let marks = term.marks().list();
|
||||
assert_eq!(marks[0].text, "echo one");
|
||||
assert_eq!(marks[0].exit, Some(0));
|
||||
assert_eq!(marks[1].text, "false");
|
||||
assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code");
|
||||
assert!(
|
||||
marks[1].row > marks[0].row,
|
||||
"the second prompt is further down the scrollback ({} vs {}) — equal rows \
|
||||
would mean the advance wasn't split at the marks",
|
||||
marks[0].row,
|
||||
marks[1].row
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zle_reading_follows_live_prompt_end_marks() {
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
|
||||
+98
-2
@@ -2546,10 +2546,21 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
|
||||
use alacritty_terminal::vte::ansi::{ClearMode, Handler as _};
|
||||
|
||||
self.cancel_scroll_anim();
|
||||
self.terminal.term.lock().grid_mut().clear_history();
|
||||
// Go through `clear_screen` rather than `grid_mut().clear_history()`:
|
||||
// it drops a selection anchored in the rows we are about to discard and
|
||||
// clamps the vi cursor back into the grid, which purging the history
|
||||
// behind the term's back would leave pointing at rows that no longer
|
||||
// exist.
|
||||
self.terminal.term.lock().clear_screen(ClearMode::Saved);
|
||||
// Image placements are anchored in absolute scrollback rows, so the
|
||||
// rows we just discarded moved every anchor. Drop them; the daemon does
|
||||
// not replay out-of-band image frames, so a browser redraws on its next
|
||||
// transmit (same reasoning as the reattach path in `adopt_relink`).
|
||||
self.terminal.images().clear();
|
||||
self.scroll_frac = 0.;
|
||||
self.terminal.marks().clear();
|
||||
self.terminal.write(vec![0x0c_u8]);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -7118,6 +7129,91 @@ mod gpui_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1x1 red placement anchored at an absolute scrollback row, built the way
|
||||
/// the decode worker hands one to the store.
|
||||
fn placed_at(anchor_row: i64) -> crate::terminal::images::PlacedImage {
|
||||
use tty7_core::core::kitty_graphics::{Image, WireFormat};
|
||||
|
||||
let mut img = Image {
|
||||
id: 1,
|
||||
number: 0,
|
||||
placement: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
cols: 0,
|
||||
rows: 0,
|
||||
data: vec![0xff, 0x00, 0x00, 0xff],
|
||||
format: WireFormat::Rgba,
|
||||
compressed: false,
|
||||
};
|
||||
let (data, width_px, height_px) = crate::terminal::images::decode(&mut img).unwrap();
|
||||
crate::terminal::images::PlacedImage {
|
||||
data,
|
||||
anchor_row,
|
||||
anchor_col: 0,
|
||||
width_px,
|
||||
height_px,
|
||||
cols: 0,
|
||||
rows: 0,
|
||||
id: 1,
|
||||
placement: 0,
|
||||
painted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clearing_the_scrollback_drops_what_was_anchored_in_it(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
|
||||
// Overflow the 24-row viewport so there is a scrollback to purge.
|
||||
let mut out = Vec::new();
|
||||
for i in 0..60 {
|
||||
out.extend_from_slice(format!("line {i}\r\n").as_bytes());
|
||||
}
|
||||
DaemonMsg::Output(out).encode(&mut daemon).unwrap();
|
||||
for _ in 0..200 {
|
||||
let filled = window
|
||||
.update(cx, |view, _, _| {
|
||||
view.terminal.term.lock().grid().history_size() > 0
|
||||
})
|
||||
.unwrap();
|
||||
if filled {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let history = view.terminal.term.lock().grid().history_size();
|
||||
assert!(history > 0, "the test needs a scrollback to clear");
|
||||
|
||||
// Both of these address rows `clear_history` is about to drop:
|
||||
// an image anchored by absolute row, and a selection reaching up
|
||||
// into the history.
|
||||
view.terminal.images().place(placed_at(history as i64));
|
||||
view.terminal.term.lock().selection = Some(Selection::new(
|
||||
SelectionType::Simple,
|
||||
Point::new(Line(-1), Column(0)),
|
||||
Side::Left,
|
||||
));
|
||||
|
||||
view.clear_scrollback(cx);
|
||||
|
||||
assert!(
|
||||
view.terminal.images().snapshot().is_empty(),
|
||||
"a stale anchor blits the frame over live output, or off-screen \
|
||||
entirely — the daemon never replays the frame to correct it"
|
||||
);
|
||||
assert!(
|
||||
view.terminal.term.lock().selection.is_none(),
|
||||
"a selection left pointing at purged rows clamps onto the \
|
||||
viewport and copies whatever text moved into them"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
|
||||
Reference in New Issue
Block a user