mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
Merge pull request #204 from l0ng-ai/fix/stale-hover-row-crash
fix(terminal): stop a stale hover cell from indexing a shrunken grid
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
//! Crash log — the panic message the OS crash reporter throws away.
|
||||
//!
|
||||
//! Most tty7 panics happen inside a gpui input callback, and those callbacks are
|
||||
//! `extern "C"`: the panic can't unwind across them, so the runtime aborts. What
|
||||
//! macOS then records is the *abort* — `panic_cannot_unwind` on top of
|
||||
//! `handle_key_event` — with no message, no `file:line`, and the original frames
|
||||
//! already unwound away. Reports like that are undiagnosable, and the GUI has no
|
||||
//! logger and no terminal to print to.
|
||||
//!
|
||||
//! So we write the two lines that matter (message + location, plus a backtrace)
|
||||
//! to `crash.log` in the config dir before the process goes down.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Rewrite the log once it passes this, so a panic loop can't grow it forever.
|
||||
const MAX_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Install the panic hook for this process. `role` labels the records, since the
|
||||
/// GUI and the daemon it spawns share one config dir. Chains to the previously
|
||||
/// installed hook, so the usual stderr output still happens when there's a
|
||||
/// terminal to see it.
|
||||
pub fn install(role: &'static str) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
record(role, info);
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Append one record. Every step is best-effort: a panic handler that panics
|
||||
/// (or fails loudly) is worse than one that loses a log line.
|
||||
fn record(role: &str, info: &std::panic::PanicHookInfo<'_>) {
|
||||
let Some(path) = log_path() else {
|
||||
return;
|
||||
};
|
||||
let thread = std::thread::current();
|
||||
let mut record = String::new();
|
||||
// `info` renders as "panicked at <file:line:col>:\n<message>" — the exact
|
||||
// pair the crash report is missing.
|
||||
let _ = write!(
|
||||
record,
|
||||
"\n=== {} {} v{} pid {} thread {:?}\n{info}\n{}\n",
|
||||
utc_timestamp(),
|
||||
role,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::process::id(),
|
||||
thread.name().unwrap_or("<unnamed>"),
|
||||
std::backtrace::Backtrace::force_capture(),
|
||||
);
|
||||
append(&path, &record);
|
||||
}
|
||||
|
||||
fn append(path: &PathBuf, record: &str) {
|
||||
use std::io::Write as _;
|
||||
let truncate = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES);
|
||||
let mut file = match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(!truncate)
|
||||
.write(true)
|
||||
.truncate(truncate)
|
||||
.open(path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = file.write_all(record.as_bytes());
|
||||
let _ = file.flush();
|
||||
}
|
||||
|
||||
fn log_path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("crash.log")
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM:SS UTC` from the epoch seconds, so a record can be lined up
|
||||
/// against an OS crash report without pulling in a date crate.
|
||||
fn utc_timestamp() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days as i64);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
|
||||
rem / 3600,
|
||||
(rem % 3600) / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch → (y, m, d).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{civil_from_days, install, log_path};
|
||||
|
||||
/// The whole point of the hook: after a panic, the message and its location
|
||||
/// are on disk. `catch_unwind` stands in for the abort — the hook runs
|
||||
/// before either outcome.
|
||||
#[test]
|
||||
fn a_panic_lands_in_the_crash_log() {
|
||||
// Same pinned temp dir the config tests use (set-once, first call wins).
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
let path = log_path().expect("a pinned config dir resolves a log path");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
install("test");
|
||||
let _ = std::panic::catch_unwind(|| panic!("crash-log probe"));
|
||||
|
||||
let body = std::fs::read_to_string(&path).expect("the hook wrote a record");
|
||||
assert!(body.contains("crash-log probe"), "message: {body}");
|
||||
// Bare file name: `panic!`'s location carries the platform's own
|
||||
// separator (`src\core\crash.rs` on Windows).
|
||||
assert!(body.contains("crash.rs:"), "location: {body}");
|
||||
assert!(body.contains("test v"), "role + version: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_matches_known_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_660), (2026, 7, 26));
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||
assert_eq!(civil_from_days(19_783), (2024, 3, 1));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod agent_hooks;
|
||||
pub mod agent_prompt;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
|
||||
+11
@@ -298,6 +298,17 @@ fn main() {
|
||||
// under this dir too, so the order matters).
|
||||
apply_config_dir_arg();
|
||||
|
||||
// Panics inside gpui's `extern "C"` input callbacks abort instead of
|
||||
// unwinding, and the OS crash report then holds the abort rather than the
|
||||
// panic — no message, no location. Record those to `crash.log` in the config
|
||||
// dir. Installed here, right after the config dir resolves, so both the GUI
|
||||
// and the daemon below are covered from their first line of real work.
|
||||
crate::core::crash::install(if std::env::args().any(|a| a == "--daemon") {
|
||||
"daemon"
|
||||
} else {
|
||||
"gui"
|
||||
});
|
||||
|
||||
// Daemon mode: when launched with `--daemon` we run the headless persistent
|
||||
// terminal server and never open a window. This is the backing process the GUI
|
||||
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
|
||||
|
||||
+65
-3
@@ -236,6 +236,24 @@ impl RemoteTerminal {
|
||||
let retry_shell = shell.clone();
|
||||
match Self::spawn_once(size, cell_w, cell_h, cwd, shell) {
|
||||
Ok(term) => Ok(term),
|
||||
Err(first_err) if daemon_not_listening(&first_err) => {
|
||||
// Nothing is on the socket: the daemon died (crash, OOM, a stray
|
||||
// `kill`) since the last pane was opened. Every later spawn would
|
||||
// fail the same way, so bring one back up and retry rather than
|
||||
// leaving the window unable to open another terminal.
|
||||
if let Err(start_err) = crate::daemon::spawn::ensure_running() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"daemon not running ({first_err}); starting one failed: {start_err}"
|
||||
));
|
||||
}
|
||||
Self::spawn_once(size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
|
||||
|second_err| {
|
||||
anyhow::anyhow!(
|
||||
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
|
||||
// A live-but-old daemon can accept the connection, panic while
|
||||
// handling Spawn, and close before replying. Restart once so an
|
||||
@@ -1430,6 +1448,21 @@ fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, e
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the failure is "nothing is listening on the socket" — the daemon is
|
||||
/// gone, as opposed to alive but unhappy. On Unix a dead daemon leaves the
|
||||
/// socket file behind (`ConnectionRefused`) or removed it on the way out
|
||||
/// (`NotFound`); on Windows the named pipe simply isn't there (`NotFound`).
|
||||
fn daemon_not_listening(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||
matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||
@@ -1604,10 +1637,14 @@ fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
/// isolation (dev vs. real config dir), exactly like every other config-dir file.
|
||||
fn connect() -> anyhow::Result<Stream> {
|
||||
transport::connect().map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"connect to daemon at {}: {e}",
|
||||
// `context`, not a formatted `anyhow!`: callers classify the failure by
|
||||
// downcasting to `io::Error` (see `daemon_not_listening`), and
|
||||
// interpolating the cause into a string would leave the chain with
|
||||
// nothing to find.
|
||||
anyhow::Error::new(e).context(format!(
|
||||
"connect to daemon at {}",
|
||||
transport::endpoint_display()
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1742,6 +1779,31 @@ mod tests {
|
||||
assert!(!daemon_disconnected_before_spawn_reply(&refused));
|
||||
}
|
||||
|
||||
/// A dead daemon is the one failure the client can fix by itself, and it
|
||||
/// must be told apart from a live daemon saying no — restarting on *that*
|
||||
/// would kill every running pane over a bad shell setting.
|
||||
#[test]
|
||||
fn only_a_dead_daemon_is_worth_starting_one_for() {
|
||||
let connect_failed = |kind| -> anyhow::Error {
|
||||
anyhow::Error::new(std::io::Error::new(kind, "no listener"))
|
||||
.context("connect to daemon at /tmp/tty7.sock")
|
||||
};
|
||||
assert!(daemon_not_listening(&connect_failed(
|
||||
std::io::ErrorKind::ConnectionRefused
|
||||
)));
|
||||
assert!(daemon_not_listening(&connect_failed(
|
||||
std::io::ErrorKind::NotFound
|
||||
)));
|
||||
|
||||
// A daemon that answered and refused, and one that hung up mid-Spawn:
|
||||
// neither is "not running", and each has its own recovery.
|
||||
let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing");
|
||||
assert!(!daemon_not_listening(&refused));
|
||||
let eof: anyhow::Error =
|
||||
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "closed").into();
|
||||
assert!(!daemon_not_listening(&eof));
|
||||
}
|
||||
|
||||
/// Without a real daemon, drive the reader path directly: a `UnixStream::pair`
|
||||
/// stands in for the connection. We hand `RemoteTerminal` one half (as if it
|
||||
/// were the attach'd socket) and push framed `DaemonMsg`s down the other, then
|
||||
|
||||
@@ -71,6 +71,16 @@ pub(super) fn grid_smart_range<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
) -> Option<SmartRange> {
|
||||
// 0) The click carries the geometry of the frame that dispatched it, and
|
||||
// the grid can shrink out from under it (a split, a window drag, a
|
||||
// replayed attach size landing on the reader thread). Both walks below
|
||||
// index `grid[click.line]` straight away, and `Grid`'s `Index<Line>`
|
||||
// only `debug_assert`s the bound — a release build walks off the
|
||||
// storage. Same guard, same reason, as `TerminalView::grid_line`.
|
||||
if click.line < term.topmost_line() || click.line > term.bottommost_line() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1) An explicit OSC 8 hyperlink run wins outright — the program told us
|
||||
// the exact extent, no guessing needed.
|
||||
if let Some((start, end)) = hyperlink_run(term, click) {
|
||||
@@ -805,6 +815,20 @@ mod tests {
|
||||
assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none());
|
||||
}
|
||||
|
||||
/// A double-click dispatched with the previous frame's geometry can name a
|
||||
/// row the grid has since dropped. Indexing it walks off the storage, and
|
||||
/// the click arrives in a gpui `extern "C"` callback where that panic
|
||||
/// aborts instead of unwinding — so the row has to be refused first.
|
||||
#[test]
|
||||
fn click_outside_the_grid_rows_yields_no_range() {
|
||||
let term = term_with(10, 2, "hello");
|
||||
// Below the last row of a shrunken grid...
|
||||
assert!(grid_smart_range(&term, Point::new(Line(2), Column(0))).is_none());
|
||||
assert!(grid_smart_range(&term, Point::new(Line(9_000), Column(0))).is_none());
|
||||
// ...and above the top of a scrollback this short.
|
||||
assert!(grid_smart_range(&term, Point::new(Line(-1), Column(0))).is_none());
|
||||
}
|
||||
|
||||
fn selected(text: &str, click: usize) -> Option<String> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
range(text, click).map(|(s, e)| chars[s..=e].iter().collect())
|
||||
|
||||
+132
-17
@@ -93,6 +93,18 @@ pub struct NativeSshParts {
|
||||
persist: Box<crate::daemon::protocol::NativeSshSpec>,
|
||||
}
|
||||
|
||||
/// An established shell daemon pane (fresh spawn or re-attach), ready to be
|
||||
/// wrapped in a view: the output of the fallible
|
||||
/// [`TerminalView::spawn_shell_terminal`], consumed by the infallible
|
||||
/// [`TerminalView::from_shell_parts`].
|
||||
pub struct ShellParts {
|
||||
terminal: RemoteTerminal,
|
||||
pane_id: u64,
|
||||
/// The explicit shell pick this pane was spawned with, if any; `None` for a
|
||||
/// re-attached pane (the pick isn't persisted).
|
||||
shell_spec: Option<ShellSpec>,
|
||||
}
|
||||
|
||||
/// See `TerminalView::drag_scroll`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DragScroll {
|
||||
@@ -816,19 +828,25 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> {
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
pub fn new(
|
||||
/// The fallible half of a shell-backed view: establish the daemon pane
|
||||
/// *before* the view is built, so a refused spawn (daemon down, spawn
|
||||
/// error) comes back as an `Err` the caller can report. Splitting it out
|
||||
/// matters beyond tidiness: the view is constructed inside `cx.new`, deep
|
||||
/// under gpui's `extern "C"` input callbacks, where a panic can't unwind
|
||||
/// and aborts the process instead. Mirrors
|
||||
/// [`Self::spawn_native_ssh_terminal`].
|
||||
///
|
||||
/// Provisional size; corrected on the first prepaint once we can measure.
|
||||
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||
/// re-`attach` to the still-running pane so its process + scrollback come
|
||||
/// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
||||
/// pick, if any). The caller only passes a `restore_pane` it has already
|
||||
/// confirmed alive, so we trust it here.
|
||||
pub fn spawn_shell_terminal(
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
restore_pane: Option<u64>,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Provisional size; corrected on the first prepaint once we can measure.
|
||||
// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||
// re-`attach` to the still-running pane so its process + scrollback come
|
||||
// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
||||
// pick, if any). The caller only passes a `restore_pane` it has already
|
||||
// confirmed alive, so we trust it here.
|
||||
) -> anyhow::Result<ShellParts> {
|
||||
let (terminal, pane_id, shell_spec) = match restore_pane {
|
||||
Some(id) => (
|
||||
RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?,
|
||||
@@ -848,9 +866,23 @@ impl TerminalView {
|
||||
(terminal, id, shell)
|
||||
}
|
||||
};
|
||||
let mut view = Self::with_terminal(terminal, pane_id, window, cx);
|
||||
view.shell_spec = shell_spec;
|
||||
Ok(view)
|
||||
Ok(ShellParts {
|
||||
terminal,
|
||||
pane_id,
|
||||
shell_spec,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrap an established shell pane (from [`Self::spawn_shell_terminal`]) in
|
||||
/// a view. Infallible by construction — see that function.
|
||||
pub fn from_shell_parts(
|
||||
parts: ShellParts,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
|
||||
view.shell_spec = parts.shell_spec;
|
||||
view
|
||||
}
|
||||
|
||||
/// Spawn a native (russh) SSH pane for `spec` and build the view around it
|
||||
@@ -1160,6 +1192,18 @@ impl TerminalView {
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
) {
|
||||
// A remembered hover cell describes the *old* geometry: after a resize
|
||||
// its row may not exist any more, and the pointer sits over a different
|
||||
// cell regardless. Forget it — the next mouse move records a fresh one.
|
||||
// (`grid_line` also refuses a stale row, so this is about not underlining
|
||||
// the wrong cell, not about safety.) The link that cell resolved to goes
|
||||
// with it: it is held in grid coordinates the reflow just moved text
|
||||
// under, so keeping it would underline whatever now sits there (and hold
|
||||
// the pointing-hand cursor over it) until the pointer moves again.
|
||||
if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) {
|
||||
self.last_hover_cell = None;
|
||||
self.hovered_link = None;
|
||||
}
|
||||
self.cell_width = cell_width;
|
||||
self.line_height = line_height;
|
||||
self.terminal.resize(
|
||||
@@ -4335,6 +4379,23 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid line a screen `row` currently maps to, or `None` when that row
|
||||
/// is outside the grid.
|
||||
///
|
||||
/// Mandatory before indexing: `Grid`'s `Index<Line>` only `debug_assert`s
|
||||
/// the bound, so a release build walks off the storage and panics on the
|
||||
/// slice check instead. A remembered cell goes stale whenever the grid
|
||||
/// shrinks under it — split a pane, drag the window smaller — and the
|
||||
/// callers here run inside gpui's `extern "C"` input callbacks, where that
|
||||
/// panic can't unwind and aborts the process.
|
||||
fn grid_line(
|
||||
term: &alacritty_terminal::Term<crate::terminal::remote::EventProxy>,
|
||||
row: usize,
|
||||
) -> Option<Line> {
|
||||
let line = Line(row as i32 - term.grid().display_offset() as i32);
|
||||
(line >= term.topmost_line() && line <= term.bottommost_line()).then_some(line)
|
||||
}
|
||||
|
||||
/// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or
|
||||
/// existing file or directory path detected in the row text). Returns true if one opened.
|
||||
pub fn open_link_at(&self, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
|
||||
@@ -4342,8 +4403,9 @@ impl TerminalView {
|
||||
return false;
|
||||
}
|
||||
let term = self.terminal.term.lock();
|
||||
let display_offset = term.grid().display_offset() as i32;
|
||||
let line = Line(row as i32 - display_offset);
|
||||
let Some(line) = Self::grid_line(&term, row) else {
|
||||
return false;
|
||||
};
|
||||
let cols = term.columns();
|
||||
if col >= cols {
|
||||
return false;
|
||||
@@ -4490,8 +4552,7 @@ impl TerminalView {
|
||||
include_loopback: bool,
|
||||
) -> Option<HoveredLink> {
|
||||
let term = self.terminal.term.lock();
|
||||
let display_offset = term.grid().display_offset() as i32;
|
||||
let line = Line(row as i32 - display_offset);
|
||||
let line = Self::grid_line(&term, row)?;
|
||||
let cols = term.columns();
|
||||
if col >= cols {
|
||||
return None;
|
||||
@@ -6790,6 +6851,60 @@ mod gpui_tests {
|
||||
(window, daemon_side)
|
||||
}
|
||||
|
||||
/// A hover cell remembered while the pane was tall names a row the grid no
|
||||
/// longer has once the pane shrinks (a vertical split, a smaller window).
|
||||
/// Resolving it must decline rather than index the grid — this path runs
|
||||
/// from `ModifiersChanged` (the ⌘ of the very ⌘⇧D that split the pane), an
|
||||
/// `extern "C"` callback where the panic can't unwind and aborts the app.
|
||||
#[gpui::test]
|
||||
fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
// Hover the last row of the 24-row grid, then shrink to 8 rows.
|
||||
view.hover_link_at(0, 23, true, cx);
|
||||
view.terminal.resize(TermSize::new(80, 8), 8, 17);
|
||||
// `set_grid_size` drops the stale cell in the real app; pin it
|
||||
// here so the guard inside the lookup is what's under test.
|
||||
view.last_hover_cell = Some((0, 23));
|
||||
assert!(
|
||||
!view.refresh_link_hover(true, cx),
|
||||
"a row outside the grid can't hold a link"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The other half of the fix: the pane that shrank forgets the hover it was
|
||||
/// holding, rather than carrying a cell (and the underline it resolved) that
|
||||
/// now names different text.
|
||||
#[gpui::test]
|
||||
fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
// Pin a known geometry first — what the test window measured for
|
||||
// itself is the element's business, and this is about the
|
||||
// transition. Then hover the last row of those 24.
|
||||
view.set_grid_size(80, 24, px(8.), px(17.));
|
||||
view.hover_link_at(0, 23, true, cx);
|
||||
assert_eq!(view.last_hover_cell, Some((0, 23)));
|
||||
view.hovered_link = Some(HoveredLink {
|
||||
line: 23,
|
||||
start: 0,
|
||||
end: 3,
|
||||
});
|
||||
// The same geometry again changes nothing...
|
||||
view.set_grid_size(80, 24, px(8.), px(17.));
|
||||
assert_eq!(view.last_hover_cell, Some((0, 23)));
|
||||
// ...but a split (or a window drag) that shrinks the pane does.
|
||||
view.set_grid_size(80, 8, px(8.), px(17.));
|
||||
assert!(view.last_hover_cell.is_none(), "the cell is stale");
|
||||
assert!(view.hovered_link.is_none(), "so is the link it resolved");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn title_events_drive_the_tab_title(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
|
||||
+83
-22
@@ -932,10 +932,16 @@ impl Tty7App {
|
||||
// First run (no session file): the very first terminal has no
|
||||
// predecessor to inherit from, so start in the app's current
|
||||
// directory (None → default behavior).
|
||||
None => {
|
||||
let first = new_terminal(font_size, None, None, None, window, cx);
|
||||
(vec![Tab::new(Pane::leaf(first))], 0)
|
||||
}
|
||||
None => match new_terminal(font_size, None, None, None, window, cx) {
|
||||
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
|
||||
// The daemon we just tried to start isn't answering. A window
|
||||
// with no tabs is a legal state (it shows the home page), and
|
||||
// far better than taking the launch down over it.
|
||||
Err(e) => {
|
||||
log::error!("first terminal failed to start: {e}");
|
||||
(Vec::new(), 0)
|
||||
}
|
||||
},
|
||||
// A saved session (with tabs, or an empty home-page state): rebuild it
|
||||
// the same way a daemon restart does.
|
||||
some => tabs_from_session(some, font_size, window, cx),
|
||||
@@ -1354,7 +1360,13 @@ impl Tty7App {
|
||||
return;
|
||||
};
|
||||
let alive = alive_panes();
|
||||
let pane = session_to_pane(&st.pane, &alive, self.font_size, window, cx);
|
||||
let Some(pane) = session_to_pane(&st.pane, &alive, self.font_size, window, cx) else {
|
||||
// Nothing came back (an unreachable daemon). Put the entry back so
|
||||
// the tab is still reopenable once the daemon is up again.
|
||||
window.push_notification("Could not reopen the tab: no terminal started", cx);
|
||||
self.closed.push(st);
|
||||
return;
|
||||
};
|
||||
// Leaving the current tab for the reopened one; snapshot its focused
|
||||
// pane so switching back restores it (same as `activate`).
|
||||
self.remember_active_pane(window, cx);
|
||||
@@ -2801,7 +2813,14 @@ impl Tty7App {
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).local_cwd())
|
||||
});
|
||||
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
|
||||
let tab = match new_terminal(self.font_size, cwd, None, shell, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("new tab spawn failed: {e}");
|
||||
window.push_notification(format!("Could not open a terminal: {e}"), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Leaving the current tab for the new one; snapshot its focused pane
|
||||
// so switching back restores it (same as `activate`).
|
||||
self.remember_active_pane(window, cx);
|
||||
@@ -2915,7 +2934,14 @@ impl Tty7App {
|
||||
}
|
||||
} else {
|
||||
let shell = target.read(cx).shell_spec();
|
||||
new_terminal(self.font_size, cwd, None, shell, window, cx)
|
||||
match new_terminal(self.font_size, cwd, None, shell, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("split spawn failed: {e}");
|
||||
window.push_notification(format!("Could not split the pane: {e}"), cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
if tab.pane.split_leaf(&target, axis, new.clone()) {
|
||||
@@ -3457,7 +3483,14 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx);
|
||||
let view = match new_terminal(self.font_size, Some(wt.path), None, None, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("worktree tab spawn failed: {e}");
|
||||
window.push_notification(format!("Could not open a terminal: {e}"), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.remember_active_pane(window, cx);
|
||||
self.maximized = None;
|
||||
let insert_at = self.new_tab_insert_at(cx);
|
||||
@@ -5817,7 +5850,12 @@ fn tabs_from_session(
|
||||
let alive = alive_panes();
|
||||
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
|
||||
for st in &session.tabs {
|
||||
let pane = session_to_pane(&st.pane, &alive, font_size, window, cx);
|
||||
// A tab whose every leaf failed to come back has nothing to show; drop
|
||||
// it rather than restore an empty frame (or, worse, abort the launch).
|
||||
let Some(pane) = session_to_pane(&st.pane, &alive, font_size, window, cx) else {
|
||||
log::error!("dropping a restored tab: no pane in it could be started");
|
||||
continue;
|
||||
};
|
||||
tabs.push(Tab {
|
||||
pane,
|
||||
name: st.name.clone(),
|
||||
@@ -5831,8 +5869,9 @@ fn tabs_from_session(
|
||||
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
|
||||
});
|
||||
}
|
||||
// Clamp the saved active index into the rebuilt range.
|
||||
let active = session.active.min(tabs.len() - 1);
|
||||
// Clamp the saved active index into the rebuilt range (which can be empty
|
||||
// when nothing restored).
|
||||
let active = session.active.min(tabs.len().saturating_sub(1));
|
||||
(tabs, active)
|
||||
}
|
||||
|
||||
@@ -5840,13 +5879,17 @@ fn tabs_from_session(
|
||||
/// `pane_id` is still alive in the daemon re-`attach`es (process + scrollback
|
||||
/// intact); otherwise it spawns a fresh shell in the saved cwd. `alive` is the
|
||||
/// daemon's current pane set, computed once by the caller.
|
||||
///
|
||||
/// `None` when nothing under this node could be started (an unreachable
|
||||
/// daemon): restore drops what it can't rebuild instead of leaving `Empty`
|
||||
/// nodes — which every tree operation ignores — in a live tab.
|
||||
fn session_to_pane(
|
||||
sp: &SessionPane,
|
||||
alive: &std::collections::HashSet<u64>,
|
||||
font_size: f32,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Pane {
|
||||
) -> Option<Pane> {
|
||||
match sp {
|
||||
SessionPane::Leaf {
|
||||
cwd,
|
||||
@@ -5867,7 +5910,7 @@ fn session_to_pane(
|
||||
if let Some(spec) = ssh_spec.clone() {
|
||||
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
|
||||
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
|
||||
Ok(view) => return Pane::leaf(view),
|
||||
Ok(view) => return Some(Pane::leaf(view)),
|
||||
// Keep restore alive: fall through to a local shell in
|
||||
// this slot rather than aborting startup.
|
||||
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
|
||||
@@ -5876,7 +5919,13 @@ fn session_to_pane(
|
||||
}
|
||||
// A shell pick isn't persisted in the session, so a stale pane that
|
||||
// must respawn comes back on the default shell.
|
||||
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
|
||||
let view = match new_terminal(font_size, cwd.clone(), restore, None, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("restoring pane failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// A pane that could NOT re-attach lost its running agent with the
|
||||
// daemon; when we captured that agent's native session id, hand
|
||||
// the fresh shell its resume command so the conversation picks up
|
||||
@@ -5890,20 +5939,32 @@ fn session_to_pane(
|
||||
{
|
||||
view.read(cx).run_command_line(&cmd);
|
||||
}
|
||||
Pane::leaf(view)
|
||||
Some(Pane::leaf(view))
|
||||
}
|
||||
SessionPane::Split { axis, ratio, a, b } => {
|
||||
let axis = match axis {
|
||||
SessionAxis::Horizontal => Axis::Horizontal,
|
||||
SessionAxis::Vertical => Axis::Vertical,
|
||||
};
|
||||
let a = session_to_pane(a, alive, font_size, window, cx);
|
||||
let b = session_to_pane(b, alive, font_size, window, cx);
|
||||
Pane::split_node(axis, *ratio, a, b)
|
||||
// One side failing collapses the split onto the survivor, exactly
|
||||
// as closing that pane by hand would.
|
||||
match (
|
||||
session_to_pane(a, alive, font_size, window, cx),
|
||||
session_to_pane(b, alive, font_size, window, cx),
|
||||
) {
|
||||
(Some(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)),
|
||||
(Some(only), None) | (None, Some(only)) => Some(only),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shell-backed terminal view, wiring the per-pane subscriptions every
|
||||
/// pane needs. Fallible: the daemon can refuse the spawn (it died, it's
|
||||
/// wedged, the shell doesn't exist), and every caller here runs inside a gpui
|
||||
/// input callback, where a panic can't unwind and would abort the app instead
|
||||
/// of surfacing the failure. Report it, don't `expect` it.
|
||||
fn new_terminal(
|
||||
font_size: f32,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
@@ -5911,10 +5972,10 @@ fn new_terminal(
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Entity<TerminalView> {
|
||||
) -> anyhow::Result<Entity<TerminalView>> {
|
||||
let parts = TerminalView::spawn_shell_terminal(working_directory, restore_pane, shell)?;
|
||||
let view = cx.new(|cx| {
|
||||
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
|
||||
.expect("failed to start terminal");
|
||||
let mut view = TerminalView::from_shell_parts(parts, window, cx);
|
||||
// Inherit the current global font size so new panes match existing ones.
|
||||
view.font_size = px(font_size);
|
||||
view
|
||||
@@ -5940,7 +6001,7 @@ fn new_terminal(
|
||||
)
|
||||
.detach();
|
||||
watch_pane_focus(&view, window, cx);
|
||||
view
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Re-render the app whenever `view` takes focus. Nothing else does this: a
|
||||
|
||||
Reference in New Issue
Block a user