mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 00:06:18 +00:00
Merge branch 'master' into auto-issue-5478-8692ae
This commit is contained in:
Generated
+1
@@ -801,6 +801,7 @@ dependencies = [
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"signal-hook",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
||||
@@ -57,6 +57,9 @@ schemars = { version = "1.2.1", features = ["derive"] }
|
||||
[patch.crates-io]
|
||||
portable-pty = { path = "vendor/portable-pty" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
signal-hook = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# logind's delay inhibitor lets the server save before host shutdown kills panes.
|
||||
zbus = { version = "5.19", default-features = false, features = ["tokio"] }
|
||||
|
||||
@@ -270,7 +270,7 @@ impl App {
|
||||
cwd,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
self.pane_host_terminal_appearance(),
|
||||
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
||||
&launch_env,
|
||||
self.event_tx.clone(),
|
||||
|
||||
+5
-2
@@ -272,7 +272,10 @@ impl App {
|
||||
AppEvent::PaneDied {
|
||||
pane_id,
|
||||
exit_reason,
|
||||
} if exit_reason.requires_session_checkpoint() && self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id)
|
||||
} if (exit_reason.requires_session_checkpoint()
|
||||
|| (*exit_reason == crate::platform::ChildExitReason::Exited
|
||||
&& (self.server_hangup_probe)()))
|
||||
&& self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id)
|
||||
);
|
||||
if checkpointed_pane_exit {
|
||||
self.checkpoint_session_before_pane_exit();
|
||||
@@ -611,7 +614,7 @@ impl App {
|
||||
cwd,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
self.pane_host_terminal_appearance(),
|
||||
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
||||
&launch_env,
|
||||
self.event_tx.clone(),
|
||||
|
||||
@@ -99,7 +99,7 @@ impl App {
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
let host_terminal_theme = self.state.host_terminal_theme;
|
||||
let host_terminal_appearance = self.state.host_terminal_appearance;
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) {
|
||||
Ok(env) => env,
|
||||
Err((code, message)) => return encode_error(id, &code, message),
|
||||
@@ -415,7 +415,7 @@ impl App {
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
let host_terminal_theme = self.state.host_terminal_theme;
|
||||
let host_terminal_appearance = self.state.host_terminal_appearance;
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let cwd = pane
|
||||
.cwd
|
||||
.as_ref()
|
||||
|
||||
@@ -71,7 +71,7 @@ impl App {
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
let host_terminal_theme = self.state.host_terminal_theme;
|
||||
let host_terminal_appearance = self.state.host_terminal_appearance;
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let previous_focus = self.state.current_pane_focus_target();
|
||||
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
|
||||
return encode_error(id, "pane_not_found", "pane not found");
|
||||
|
||||
@@ -127,6 +127,7 @@ impl App {
|
||||
};
|
||||
let (rows, cols) = self.state.new_pane_size(new_pane_placement);
|
||||
let previous_focus = self.state.current_pane_focus_target();
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
|
||||
return encode_error(id, "workspace_not_found", "workspace not found");
|
||||
};
|
||||
@@ -140,7 +141,7 @@ impl App {
|
||||
extra_env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
host_terminal_appearance,
|
||||
params.focus || placement == PluginPanePlacement::Zoomed,
|
||||
);
|
||||
let (tab_idx, new_pane) = match result {
|
||||
@@ -206,6 +207,7 @@ impl App {
|
||||
Err((code, message)) => return encode_error(id, &code, message),
|
||||
};
|
||||
let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone);
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
|
||||
return encode_error(id, "workspace_not_found", "workspace not found");
|
||||
};
|
||||
@@ -217,7 +219,7 @@ impl App {
|
||||
extra_env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
host_terminal_appearance,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ impl App {
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
let host_terminal_theme = self.state.host_terminal_theme;
|
||||
let host_terminal_appearance = self.state.host_terminal_appearance;
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let extra_env = match super::env::normalize_launch_env(env) {
|
||||
Ok(env) => env,
|
||||
Err((code, message)) => return encode_error(id, &code, message),
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ impl App {
|
||||
cols,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
self.pane_host_terminal_appearance(),
|
||||
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
||||
self.event_tx.clone(),
|
||||
self.render_notify.clone(),
|
||||
|
||||
@@ -373,6 +373,7 @@ impl App {
|
||||
.state
|
||||
.new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay);
|
||||
let (env, _) = self.custom_command_env();
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
|
||||
let ws = self
|
||||
.state
|
||||
@@ -400,7 +401,7 @@ impl App {
|
||||
env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
host_terminal_appearance,
|
||||
)?;
|
||||
let new_pane_id = new_pane.pane_id;
|
||||
self.terminal_runtimes
|
||||
@@ -470,6 +471,7 @@ impl App {
|
||||
})
|
||||
});
|
||||
|
||||
let host_terminal_appearance = self.pane_host_terminal_appearance();
|
||||
let (tab_idx, new_pane, workspace_id) = {
|
||||
let ws = self
|
||||
.state
|
||||
@@ -487,7 +489,7 @@ impl App {
|
||||
extra_env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
host_terminal_appearance,
|
||||
true,
|
||||
);
|
||||
let (tab_idx, new_pane) = match result {
|
||||
|
||||
+82
-1
@@ -140,12 +140,15 @@ pub struct App {
|
||||
pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig,
|
||||
pub(crate) agent_metadata_deadline: Option<Instant>,
|
||||
pub(crate) pending_agent_resume_deadline: Option<Instant>,
|
||||
pub(crate) pending_host_appearance_deadline: Option<Instant>,
|
||||
pane_host_appearance: Option<crate::terminal_theme::HostAppearance>,
|
||||
startup_per_agent_delay: Duration,
|
||||
next_agent_resume_at: Option<Instant>,
|
||||
pub(crate) session_save_deadline: Option<Instant>,
|
||||
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
|
||||
session_writer: Arc<std::sync::Mutex<crate::persist::SessionWriter>>,
|
||||
pane_exit_checkpoint_pending: bool,
|
||||
server_hangup_probe: fn() -> bool,
|
||||
pub(crate) detached_process_children: Vec<std::process::Child>,
|
||||
tab_bar_status_generation: u64,
|
||||
tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
|
||||
@@ -651,6 +654,8 @@ impl App {
|
||||
loaded_host_cursor: config.ui.host_cursor,
|
||||
agent_metadata_deadline: None,
|
||||
pending_agent_resume_deadline: None,
|
||||
pending_host_appearance_deadline: None,
|
||||
pane_host_appearance: None,
|
||||
startup_per_agent_delay: Duration::from_millis(
|
||||
config.session.startup_per_agent_delay_ms.into(),
|
||||
),
|
||||
@@ -659,6 +664,11 @@ impl App {
|
||||
session_save_thread: None,
|
||||
session_writer,
|
||||
pane_exit_checkpoint_pending: false,
|
||||
#[cfg(not(test))]
|
||||
server_hangup_probe: crate::platform::server_hangup_is_recent,
|
||||
// Real SIGHUP evidence is process-global; tests opt in per App.
|
||||
#[cfg(test)]
|
||||
server_hangup_probe: || false,
|
||||
detached_process_children: Vec::new(),
|
||||
tab_bar_status_generation: 0,
|
||||
tab_bar_datetimes: Vec::new(),
|
||||
@@ -3300,7 +3310,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_autosave_replaces_a_signaled_exit_checkpoint() {
|
||||
fn normal_autosave_preserves_a_signaled_exit_checkpoint() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let config_home = unique_temp_path("signaled-pane-autosave");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &config_home);
|
||||
@@ -3326,12 +3336,83 @@ mod tests {
|
||||
}
|
||||
app.save_session_on_shutdown();
|
||||
|
||||
assert!(crate::persist::load().is_some());
|
||||
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let _ = std::fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_last_pane_exit_clears_saved_session() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let config_home = unique_temp_path("normal-last-pane-exit");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var(crate::session::SESSION_ENV_VAR);
|
||||
|
||||
let mut app = test_app();
|
||||
app.policy.persist_session = true;
|
||||
let workspace = Workspace::test_new("closed");
|
||||
let pane_id = workspace.tabs[0].root_pane;
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.ensure_test_terminals();
|
||||
app.save_session_now();
|
||||
|
||||
app.handle_internal_event(AppEvent::PaneDied {
|
||||
pane_id,
|
||||
exit_reason: crate::platform::ChildExitReason::Exited,
|
||||
});
|
||||
app.start_background_session_save();
|
||||
if let Some(thread) = app.session_save_thread.take() {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
app.save_session_on_shutdown();
|
||||
assert!(crate::persist::load().is_none());
|
||||
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let _ = std::fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn server_hangup_preserves_unsignaled_pane_exit_checkpoint() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let config_home = unique_temp_path("hangup-unsignaled-pane-exit");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var(crate::session::SESSION_ENV_VAR);
|
||||
|
||||
let mut app = test_app();
|
||||
app.policy.persist_session = true;
|
||||
let mut workspace = Workspace::test_new("preserved");
|
||||
let first_pane = workspace.tabs[0].root_pane;
|
||||
let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.ensure_test_terminals();
|
||||
app.save_session_now();
|
||||
|
||||
app.server_hangup_probe = || true;
|
||||
for pane_id in [first_pane, second_pane] {
|
||||
app.handle_internal_event(AppEvent::PaneDied {
|
||||
pane_id,
|
||||
exit_reason: crate::platform::ChildExitReason::Exited,
|
||||
});
|
||||
}
|
||||
assert!(app.state.workspaces.is_empty());
|
||||
app.start_background_session_save();
|
||||
if let Some(thread) = app.session_save_thread.take() {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
app.save_session_on_shutdown();
|
||||
|
||||
let snapshot = crate::persist::load().expect("hangup checkpoint must survive autosave");
|
||||
assert_eq!(snapshot.workspaces.len(), 1);
|
||||
assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 2);
|
||||
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let _ = std::fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_mutation_after_pane_exit_checkpoint_wins_on_shutdown() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ impl App {
|
||||
crate::pane::AgentDetection::Disabled,
|
||||
app.state.pane_scrollback_limit_bytes,
|
||||
app.state.host_terminal_theme,
|
||||
app.state.host_terminal_appearance,
|
||||
app.pane_host_terminal_appearance(),
|
||||
app.event_tx.clone(),
|
||||
app.render_notify.clone(),
|
||||
app.render_dirty.clone(),
|
||||
@@ -86,7 +86,7 @@ impl App {
|
||||
crate::pane::AgentDetection::Disabled,
|
||||
app.state.pane_scrollback_limit_bytes,
|
||||
app.state.host_terminal_theme,
|
||||
app.state.host_terminal_appearance,
|
||||
app.pane_host_terminal_appearance(),
|
||||
app.event_tx.clone(),
|
||||
app.render_notify.clone(),
|
||||
app.render_dirty.clone(),
|
||||
|
||||
@@ -167,6 +167,7 @@ impl App {
|
||||
self.next_agent_manifest_update_check,
|
||||
self.agent_metadata_deadline,
|
||||
self.pending_agent_resume_deadline,
|
||||
self.pending_host_appearance_deadline,
|
||||
self.session_save_deadline,
|
||||
self.next_tab_bar_status_deadline(),
|
||||
render_deadline,
|
||||
|
||||
+6
-1
@@ -65,6 +65,11 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.pane_exit_checkpoint_pending && !self.state.session_dirty {
|
||||
self.session_save_deadline = None;
|
||||
return;
|
||||
}
|
||||
|
||||
self.reap_finished_session_save();
|
||||
if self.session_save_thread.is_some() {
|
||||
self.session_save_deadline = Some(Instant::now() + Duration::from_millis(250));
|
||||
@@ -116,7 +121,7 @@ impl App {
|
||||
pub(crate) fn finish_checkpointed_pane_exit(&mut self) {
|
||||
if self.pane_exit_checkpoint_pending {
|
||||
self.state.session_dirty = false;
|
||||
self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE);
|
||||
self.session_save_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-14
@@ -1,31 +1,98 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::App;
|
||||
use crate::terminal_theme::HostAppearance;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_host_terminal_appearance_state(
|
||||
&mut self,
|
||||
appearance: Option<crate::terminal_theme::HostAppearance>,
|
||||
appearance: Option<HostAppearance>,
|
||||
explicit: bool,
|
||||
) -> bool {
|
||||
if self.state.host_terminal_appearance == appearance
|
||||
&& self.state.host_terminal_appearance_explicit == explicit
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.state.host_terminal_appearance = appearance;
|
||||
self.state.host_terminal_appearance_explicit = explicit;
|
||||
self.apply_host_terminal_appearance_to_panes();
|
||||
self.refresh_effective_app_theme()
|
||||
self.set_host_terminal_state(self.state.host_terminal_theme, appearance, explicit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_host_terminal_theme(
|
||||
&mut self,
|
||||
theme: crate::terminal_theme::TerminalTheme,
|
||||
) -> bool {
|
||||
if theme == self.state.host_terminal_theme {
|
||||
self.set_host_terminal_state(
|
||||
theme,
|
||||
self.state.host_terminal_appearance,
|
||||
self.state.host_terminal_appearance_explicit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_host_terminal_state(
|
||||
&mut self,
|
||||
theme: crate::terminal_theme::TerminalTheme,
|
||||
appearance: Option<HostAppearance>,
|
||||
explicit: bool,
|
||||
) -> bool {
|
||||
let theme_changed = theme != self.state.host_terminal_theme;
|
||||
if theme_changed {
|
||||
self.state.host_terminal_theme = theme;
|
||||
self.apply_host_terminal_theme_to_panes();
|
||||
}
|
||||
|
||||
let previous = self.state.host_terminal_appearance;
|
||||
let appearance_changed =
|
||||
previous != appearance || self.state.host_terminal_appearance_explicit != explicit;
|
||||
if appearance_changed {
|
||||
self.state.host_terminal_appearance = appearance;
|
||||
self.state.host_terminal_appearance_explicit = explicit;
|
||||
if previous.is_some() && appearance.is_some() {
|
||||
if previous != appearance {
|
||||
// The matching OSC 11 arrives after the appearance report's round trip.
|
||||
self.pending_host_appearance_deadline
|
||||
.get_or_insert(Instant::now() + Duration::from_millis(750));
|
||||
}
|
||||
} else {
|
||||
self.pending_host_appearance_deadline = None;
|
||||
self.apply_host_terminal_appearance_to_panes();
|
||||
}
|
||||
}
|
||||
|
||||
// A foreground switch must install both facts before releasing the outgoing hold.
|
||||
let released = self.release_host_appearance_if_ready(Instant::now());
|
||||
let repainted = appearance_changed && self.refresh_effective_app_theme();
|
||||
theme_changed || released || repainted
|
||||
}
|
||||
|
||||
pub(crate) fn pane_host_terminal_appearance(&self) -> Option<HostAppearance> {
|
||||
if self.pending_host_appearance_deadline.is_some() {
|
||||
// New panes must describe the background available during the hold.
|
||||
self.state
|
||||
.host_terminal_theme
|
||||
.background
|
||||
.map(|color| color.inferred_appearance())
|
||||
.or(self.pane_host_appearance)
|
||||
} else {
|
||||
self.state.host_terminal_appearance
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release_host_appearance_if_ready(&mut self, now: Instant) -> bool {
|
||||
let Some(deadline) = self.pending_host_appearance_deadline else {
|
||||
return false;
|
||||
};
|
||||
let matches = self
|
||||
.state
|
||||
.host_terminal_theme
|
||||
.background
|
||||
.is_some_and(|color| {
|
||||
Some(color.inferred_appearance()) == self.state.host_terminal_appearance
|
||||
});
|
||||
if !matches && now < deadline {
|
||||
return false;
|
||||
}
|
||||
self.state.host_terminal_theme = theme;
|
||||
self.apply_host_terminal_theme_to_panes();
|
||||
self.pending_host_appearance_deadline = None;
|
||||
self.apply_host_terminal_appearance_to_panes();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -44,7 +111,8 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_host_terminal_appearance_to_panes(&self) {
|
||||
fn apply_host_terminal_appearance_to_panes(&mut self) {
|
||||
self.pane_host_appearance = self.state.host_terminal_appearance;
|
||||
for runtime in self.terminal_runtimes.values() {
|
||||
runtime.apply_host_terminal_appearance(self.state.host_terminal_appearance);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::*;
|
||||
use crate::terminal::{TerminalId, TerminalRuntime};
|
||||
use crate::terminal_theme::{RgbColor, TerminalTheme};
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const DARK: RgbColor = RgbColor {
|
||||
r: 17,
|
||||
g: 17,
|
||||
b: 17,
|
||||
};
|
||||
const LIGHT: RgbColor = RgbColor {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
};
|
||||
|
||||
fn app() -> App {
|
||||
let (_, api_rx) = mpsc::unbounded_channel();
|
||||
App::new(
|
||||
&crate::config::Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn child(app: &mut App) -> (TerminalId, mpsc::Receiver<Bytes>) {
|
||||
let (runtime, rx) = TerminalRuntime::test_with_channel(80, 24);
|
||||
runtime.apply_host_terminal_theme(app.state.host_terminal_theme);
|
||||
runtime.apply_host_terminal_appearance(app.pane_host_terminal_appearance());
|
||||
runtime.test_process_pty_bytes(b"\x1b[?2031h");
|
||||
let id = TerminalId::alloc();
|
||||
app.terminal_runtimes.insert(id.clone(), runtime);
|
||||
(id, rx)
|
||||
}
|
||||
|
||||
fn background(app: &mut App, color: RgbColor) {
|
||||
app.set_host_terminal_theme(TerminalTheme {
|
||||
background: Some(color),
|
||||
..app.state.host_terminal_theme
|
||||
});
|
||||
}
|
||||
|
||||
fn query(app: &App, id: &TerminalId, request: &[u8]) -> Vec<Bytes> {
|
||||
app.terminal_runtimes
|
||||
.get(id)
|
||||
.unwrap()
|
||||
.test_process_pty_bytes(request)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appearance_waits_for_background_not_foreground_or_palette() {
|
||||
let mut app = app();
|
||||
background(&mut app, DARK);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
let (id, mut rx) = child(&mut app);
|
||||
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
assert!(rx.try_recv().is_err(), "notification must wait for OSC 11");
|
||||
let mut theme = app.state.host_terminal_theme;
|
||||
theme.foreground = Some(DARK);
|
||||
theme.palette[1] = Some(LIGHT);
|
||||
app.set_host_terminal_theme(theme);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"OSC 10/palette must not release the hold"
|
||||
);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(b"\x1b[?997;1n")]
|
||||
);
|
||||
background(&mut app, LIGHT);
|
||||
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b]11;?\x07"),
|
||||
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appearance_with_matching_background_is_immediate() {
|
||||
let mut app = app();
|
||||
background(&mut app, DARK);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
let (_, mut rx) = child(&mut app);
|
||||
background(&mut app, LIGHT);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
|
||||
assert!(app.pending_host_appearance_deadline.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appearance_deadline_bounds_missing_or_inconsistent_background() {
|
||||
for missing in [false, true] {
|
||||
let mut app = app();
|
||||
background(&mut app, DARK);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
let (_, mut rx) = child(&mut app);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
if missing {
|
||||
app.set_host_terminal_theme(TerminalTheme::default());
|
||||
}
|
||||
let deadline = app.pending_host_appearance_deadline.unwrap();
|
||||
assert!(deadline <= Instant::now() + Duration::from_millis(750));
|
||||
assert!(!app.release_host_appearance_if_ready(deadline - Duration::from_nanos(1)));
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(app.release_host_appearance_if_ready(deadline));
|
||||
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
|
||||
assert!(!app.release_host_appearance_if_ready(deadline));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pane_opened_without_background_during_appearance_hold_answers_and_receives_release() {
|
||||
for (outgoing, incoming) in [
|
||||
(HostAppearance::Dark, HostAppearance::Light),
|
||||
(HostAppearance::Light, HostAppearance::Dark),
|
||||
] {
|
||||
let mut app = app();
|
||||
app.set_host_terminal_appearance_state(Some(outgoing), true);
|
||||
app.set_host_terminal_appearance_state(Some(incoming), true);
|
||||
let (id, mut rx) = child(&mut app);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(outgoing.color_scheme_report())],
|
||||
"a pane opened during the hold must answer with the known outgoing appearance"
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
let deadline = app.pending_host_appearance_deadline.unwrap();
|
||||
assert!(app.release_host_appearance_if_ready(deadline));
|
||||
assert_eq!(rx.try_recv().unwrap(), incoming.color_scheme_report());
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(incoming.color_scheme_report())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appearance_coalesces_without_extending_deadline() {
|
||||
let mut app = app();
|
||||
background(&mut app, DARK);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
let (_, mut rx) = child(&mut app);
|
||||
app.set_host_terminal_theme(TerminalTheme::default());
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
let deadline = app.pending_host_appearance_deadline;
|
||||
// A provenance-only update must not cancel a pending flip either.
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), false);
|
||||
assert_eq!(app.pending_host_appearance_deadline, deadline);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
assert_eq!(app.pending_host_appearance_deadline, deadline);
|
||||
assert!(rx.try_recv().is_err());
|
||||
background(&mut app, LIGHT);
|
||||
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appearance_baseline_establish_and_clear_are_immediate() {
|
||||
let mut app = app();
|
||||
let (id, mut rx) = child(&mut app);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(b"\x1b[?997;1n")]
|
||||
);
|
||||
assert!(app.pending_host_appearance_deadline.is_none());
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
app.set_host_terminal_appearance_state(None, false);
|
||||
assert!(app.pending_host_appearance_deadline.is_none());
|
||||
assert!(query(&app, &id, b"\x1b[?996n").is_empty());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pane_created_during_appearance_hold_gets_consistent_pair() {
|
||||
let mut app = app();
|
||||
app.state.default_shell = "/bin/sh".into();
|
||||
background(&mut app, DARK);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
let index = app
|
||||
.create_workspace_with_options(std::env::temp_dir(), false)
|
||||
.unwrap();
|
||||
let ws = &app.state.workspaces[index];
|
||||
let id = ws.terminal_id(ws.tabs[0].root_pane).unwrap().clone();
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(b"\x1b[?997;1n")]
|
||||
);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b]11;?\x07"),
|
||||
[Bytes::from_static(b"\x1b]11;rgb:1111/1111/1111\x1b\\")]
|
||||
);
|
||||
background(&mut app, LIGHT);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b[?996n"),
|
||||
[Bytes::from_static(b"\x1b[?997;2n")]
|
||||
);
|
||||
assert_eq!(
|
||||
query(&app, &id, b"\x1b]11;?\x07"),
|
||||
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
|
||||
);
|
||||
for (_, runtime) in app.terminal_runtimes.drain() {
|
||||
runtime.shutdown();
|
||||
}
|
||||
}
|
||||
+91
-9
@@ -450,23 +450,41 @@ fn hermes_installer_agent_name(argv: &[String]) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Recognize the captured installer bootstrap, not arbitrary Python source.
|
||||
// Both root literals must agree; reject escapes or quotes rather than parse Python.
|
||||
let code = code.strip_prefix("import os, re, sys\n")?;
|
||||
let (expanded, code) = if code.starts_with("import ") {
|
||||
(true, code.split_once('\n')?.1)
|
||||
} else {
|
||||
(false, code)
|
||||
};
|
||||
let rest = code.strip_prefix(HERMES_INSTALLER_PREFIX)?;
|
||||
let (root, rest) = rest.split_once("')\n")?;
|
||||
if root.is_empty() || root.contains(['\'', '\\', '\n', '\r']) {
|
||||
return None;
|
||||
}
|
||||
if expanded {
|
||||
// Recovery helpers change independently of the interactive entry point.
|
||||
return (rest.ends_with(HERMES_INSTALLER_ENTRY)
|
||||
&& rest.lines().any(|line| {
|
||||
matches!(
|
||||
line,
|
||||
"import hermes_bootstrap" | " import hermes_bootstrap"
|
||||
)
|
||||
}))
|
||||
.then(|| agent_label(Agent::Hermes).to_string());
|
||||
}
|
||||
let rest = rest
|
||||
.strip_prefix(HERMES_INSTALLER_MIDDLE)?
|
||||
.strip_prefix(root)?;
|
||||
(rest == HERMES_INSTALLER_SUFFIX).then(|| agent_label(Agent::Hermes).to_string())
|
||||
}
|
||||
|
||||
// Installer source captured in #4910. Only the installation root varies. Unknown
|
||||
// bootstrap revisions deliberately fall back to ordinary process identification.
|
||||
const HERMES_INSTALLER_PREFIX: &str = "import os, re, sys
|
||||
os.environ.pop('PYTHONHOME', None)
|
||||
const HERMES_INSTALLER_ENTRY: &str = r"from hermes_cli.main import main
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
";
|
||||
|
||||
// Short installer source captured in #4910. Only the installation root varies.
|
||||
const HERMES_INSTALLER_PREFIX: &str = "os.environ.pop('PYTHONHOME', None)
|
||||
os.environ.pop('PYTHONPATH', None)
|
||||
sys.path.insert(0, '";
|
||||
const HERMES_INSTALLER_MIDDLE: &str =
|
||||
@@ -1432,11 +1450,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn hermes_installer_capture() -> crate::platform::ForegroundJob {
|
||||
// Exact reporter capture from #4910; private path components were redacted.
|
||||
let capture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
hermes_installer_capture_from(include_str!(
|
||||
"../../tests/fixtures/hermes-installer-process-info-4910.json"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn hermes_installer_capture_from(capture: &str) -> crate::platform::ForegroundJob {
|
||||
let capture: serde_json::Value = serde_json::from_str(capture).unwrap();
|
||||
let info = &capture["result"]["process_info"];
|
||||
crate::platform::ForegroundJob {
|
||||
process_group_id: info["foreground_process_group_id"].as_u64().unwrap() as u32,
|
||||
@@ -1471,6 +1491,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_agent_in_job_detects_newer_hermes_installer_capture() {
|
||||
let mut job = hermes_installer_capture_from(include_str!(
|
||||
"../../tests/fixtures/hermes-installer-process-info-5068.json"
|
||||
));
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
Some((Agent::Hermes, "hermes".to_string()))
|
||||
);
|
||||
let process = job.processes.last_mut().unwrap();
|
||||
let argv = process.argv.as_mut().unwrap();
|
||||
argv[3] = argv[3]
|
||||
.replace(
|
||||
"/Users/username/.hermes/hermes-agent",
|
||||
"/opt/another install/hermes-agent",
|
||||
)
|
||||
.replace(
|
||||
"import contextlib, hashlib, io, pathlib, shutil, subprocess",
|
||||
"import contextlib, hashlib, io, pathlib, shutil, subprocess, warnings",
|
||||
);
|
||||
argv.push("--resume".into());
|
||||
argv.push("session-id".into());
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
Some((Agent::Hermes, "hermes".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_hermes_installer_rejects_non_entry_source_and_helper_modes() {
|
||||
let job = hermes_installer_capture_from(include_str!(
|
||||
"../../tests/fixtures/hermes-installer-process-info-5068.json"
|
||||
));
|
||||
let mut argv = job.processes.last().unwrap().argv.clone().unwrap();
|
||||
let original = argv[3].clone();
|
||||
for code in [
|
||||
"print('hermes_cli.main')".to_string(),
|
||||
format!("'''{original}'''"),
|
||||
original.lines().map(|line| format!("# {line}\n")).collect(),
|
||||
original.replace(
|
||||
"from hermes_cli.main import main",
|
||||
"from unrelated.main import main",
|
||||
),
|
||||
original.replace("sys.exit(main())", "sys.exit(0)"),
|
||||
original.replace("import hermes_bootstrap", "import unrelated_bootstrap"),
|
||||
original.replace("/Users/username", r"/Users/has\escape"),
|
||||
original.replace("/Users/username", "/Users/has'quote"),
|
||||
] {
|
||||
argv[3] = code;
|
||||
assert_eq!(hermes_installer_agent_name(&argv), None);
|
||||
}
|
||||
argv[3] = original;
|
||||
for mode in ["--run-module", "--print-runtime-command"] {
|
||||
argv.truncate(4);
|
||||
argv.push(mode.into());
|
||||
assert_eq!(hermes_installer_agent_name(&argv), None);
|
||||
}
|
||||
argv.truncate(4);
|
||||
argv[1] = "-m".into();
|
||||
assert_eq!(hermes_installer_agent_name(&argv), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_installer_signature_allows_another_install_root() {
|
||||
let job = hermes_installer_capture();
|
||||
|
||||
+6
-3
@@ -157,8 +157,8 @@ pub(crate) fn prepare_socket_path(
|
||||
fn stale_socket_connect_error(kind: io::ErrorKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound | io::ErrorKind::TimedOut
|
||||
) || (cfg!(windows) && kind == io::ErrorKind::WouldBlock)
|
||||
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound
|
||||
) || (cfg!(windows) && matches!(kind, io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock))
|
||||
}
|
||||
|
||||
pub(crate) fn local_stream_peer_closed(stream: &mut LocalStream) -> io::Result<bool> {
|
||||
@@ -382,7 +382,10 @@ mod tests {
|
||||
fn stale_socket_connect_errors_keep_unix_would_block_strict() {
|
||||
assert!(stale_socket_connect_error(io::ErrorKind::ConnectionRefused));
|
||||
assert!(stale_socket_connect_error(io::ErrorKind::NotFound));
|
||||
assert!(stale_socket_connect_error(io::ErrorKind::TimedOut));
|
||||
assert_eq!(
|
||||
stale_socket_connect_error(io::ErrorKind::TimedOut),
|
||||
cfg!(windows)
|
||||
);
|
||||
assert_eq!(
|
||||
stale_socket_connect_error(io::ErrorKind::WouldBlock),
|
||||
cfg!(windows)
|
||||
|
||||
+3
-2
@@ -4019,16 +4019,17 @@ impl PaneRuntime {
|
||||
(release_tx, writer)
|
||||
}
|
||||
|
||||
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
|
||||
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
|
||||
let _content_write_guard = match self.content_write_lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
self.content_seq.fetch_add(1, Ordering::AcqRel);
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
|
||||
let result = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
|
||||
self.content_seq.fetch_add(1, Ordering::Release);
|
||||
self.compression.wake();
|
||||
result.terminal_responses
|
||||
}
|
||||
|
||||
pub(crate) fn test_with_scrollback_bytes(
|
||||
|
||||
@@ -226,6 +226,19 @@ pub(crate) use unix_common::{
|
||||
classify_child_exit, poll_fd_readable, read_fd, shared_ssh_control_path,
|
||||
};
|
||||
|
||||
// Tests inject their own probe because the recorded hangup is process-global.
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
pub(crate) fn server_hangup_is_recent() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix_common::server_hangup_is_recent()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason {
|
||||
ChildExitReason::Exited
|
||||
|
||||
@@ -319,19 +319,47 @@ fn set_sigpipe_disposition(handler: libc::sighandler_t) {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn discard_signal(_signal: libc::c_int) {}
|
||||
static LAST_SERVER_HANGUP_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
fn monotonic_millis() -> u64 {
|
||||
let mut now: libc::timespec = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) } != 0 {
|
||||
return 0;
|
||||
}
|
||||
(now.tv_sec as u64)
|
||||
.saturating_mul(1000)
|
||||
.saturating_add(now.tv_nsec as u64 / 1_000_000)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
fn record_server_hangup() {
|
||||
LAST_SERVER_HANGUP_MS.store(monotonic_millis(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn within_server_hangup_window(hangup_ms: u64, now_ms: u64) -> bool {
|
||||
hangup_ms != 0
|
||||
&& now_ms
|
||||
.checked_sub(hangup_ms)
|
||||
.is_some_and(|elapsed| elapsed <= 5_000)
|
||||
}
|
||||
|
||||
pub(crate) fn server_hangup_is_recent() -> bool {
|
||||
within_server_hangup_window(
|
||||
LAST_SERVER_HANGUP_MS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
monotonic_millis(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The server must outlive the terminal or shell that launched it, like tmux.
|
||||
/// Installed at process start so SIGHUP cannot stop the server before its event
|
||||
/// loop runs. A handler, unlike SIG_IGN, resets to the default on exec, so
|
||||
/// child processes keep normal hangup behavior.
|
||||
pub(crate) fn ignore_server_hangup() {
|
||||
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
|
||||
action.sa_sigaction = discard_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
|
||||
action.sa_flags = libc::SA_RESTART;
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut action.sa_mask);
|
||||
libc::sigaction(libc::SIGHUP, &action, std::ptr::null_mut());
|
||||
// Record before async pane-exit handling, even if Tokio has not polled SIGHUP yet.
|
||||
if let Err(err) =
|
||||
unsafe { signal_hook::low_level::register(libc::SIGHUP, record_server_hangup) }
|
||||
{
|
||||
tracing::warn!(%err, "failed to install SIGHUP handler");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +645,22 @@ pub(crate) fn set_default_plugin_pane_pwd(env: &mut Vec<(String, String)>, cwd:
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_hangup_evidence_expires() {
|
||||
assert!(!within_server_hangup_window(0, 100));
|
||||
assert!(within_server_hangup_window(100, 100));
|
||||
assert!(within_server_hangup_window(100, 5_100));
|
||||
assert!(!within_server_hangup_window(100, 5_101));
|
||||
assert!(!within_server_hangup_window(100, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_hangup_is_recorded_before_async_handling() {
|
||||
ignore_server_hangup();
|
||||
assert_eq!(unsafe { libc::raise(libc::SIGHUP) }, 0);
|
||||
assert!(server_hangup_is_recent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_signal_monitor_survives_hangup_and_reports_terminate() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -625,12 +669,15 @@ mod tests {
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
ignore_server_hangup();
|
||||
spawn_server_signal_monitor(move |signal| {
|
||||
let _ = tx.send(signal);
|
||||
});
|
||||
|
||||
// An unhandled SIGHUP would end the test process here.
|
||||
assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGHUP) }, 0);
|
||||
// raise() runs the handler on this thread before returning, unlike kill().
|
||||
assert_eq!(unsafe { libc::raise(libc::SIGHUP) }, 0);
|
||||
assert!(server_hangup_is_recent());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
|
||||
@@ -845,11 +845,11 @@ impl HeadlessServer {
|
||||
if outer_terminal_focus == Some(true) {
|
||||
self.app.state.mark_active_tab_seen();
|
||||
}
|
||||
self.app.set_host_terminal_appearance_state(
|
||||
self.app.set_host_terminal_state(
|
||||
host_terminal_theme,
|
||||
host_terminal_appearance,
|
||||
host_terminal_appearance_explicit,
|
||||
);
|
||||
self.app.set_host_terminal_theme(host_terminal_theme);
|
||||
}
|
||||
|
||||
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
|
||||
@@ -2270,11 +2270,11 @@ impl HeadlessServer {
|
||||
if !client.shell_surface_active || self.foreground_client_id != Some(client_id) {
|
||||
return false;
|
||||
}
|
||||
let mut changed = self.app.set_host_terminal_appearance_state(
|
||||
let changed = self.app.set_host_terminal_state(
|
||||
client.host_terminal_theme,
|
||||
client.host_terminal_appearance,
|
||||
client.host_terminal_appearance_explicit,
|
||||
);
|
||||
changed |= self.app.set_host_terminal_theme(client.host_terminal_theme);
|
||||
if changed {
|
||||
self.resize_shared_runtime_to_effective_size_before_input();
|
||||
}
|
||||
@@ -3365,6 +3365,7 @@ impl HeadlessServer {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= self.app.release_host_appearance_if_ready(now);
|
||||
changed |= self.app.handle_tab_bar_status_tasks(now);
|
||||
|
||||
if geometry_dirty {
|
||||
|
||||
@@ -4019,6 +4019,101 @@ fn client_shell_host_theme_follows_foreground_client() {
|
||||
assert!(!server.app.state.host_terminal_appearance_explicit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreground_switch_coalesces_pending_appearance_before_releasing_colors() {
|
||||
use crate::terminal::{TerminalId, TerminalRuntime};
|
||||
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
|
||||
|
||||
for incoming in [Some(HostAppearance::Dark), None] {
|
||||
let mut server = test_headless_server();
|
||||
server.app.set_host_terminal_theme(TerminalTheme {
|
||||
background: Some(RgbColor {
|
||||
r: 17,
|
||||
g: 17,
|
||||
b: 17,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
server
|
||||
.app
|
||||
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
|
||||
runtime.apply_host_terminal_theme(server.app.state.host_terminal_theme);
|
||||
runtime.apply_host_terminal_appearance(Some(HostAppearance::Dark));
|
||||
runtime.test_process_pty_bytes(b"\x1b[?2031h");
|
||||
let id = TerminalId::alloc();
|
||||
server.app.terminal_runtimes.insert(id.clone(), runtime);
|
||||
server
|
||||
.app
|
||||
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
let mut client = ClientConnection::new(
|
||||
(80, 24),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
2,
|
||||
RenderEncoding::SemanticFrame,
|
||||
None,
|
||||
);
|
||||
client.host_terminal_theme.background = Some(RgbColor {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
});
|
||||
client.host_terminal_appearance = incoming;
|
||||
client.host_terminal_appearance_explicit = incoming.is_some();
|
||||
server.clients.insert(2, client);
|
||||
server.foreground_client_id = Some(2);
|
||||
server.sync_foreground_client_state();
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"must not announce outgoing client A Light"
|
||||
);
|
||||
let expected =
|
||||
incoming.map(|appearance| bytes::Bytes::from_static(appearance.color_scheme_report()));
|
||||
assert_eq!(
|
||||
server
|
||||
.app
|
||||
.terminal_runtimes
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.test_process_pty_bytes(b"\x1b[?996n"),
|
||||
expected.into_iter().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_host_appearance_is_released_by_headless_deadline() {
|
||||
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
|
||||
|
||||
let mut server = test_headless_server();
|
||||
server.app.set_host_terminal_theme(TerminalTheme {
|
||||
background: Some(RgbColor {
|
||||
r: 17,
|
||||
g: 17,
|
||||
b: 17,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
server
|
||||
.app
|
||||
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
|
||||
server
|
||||
.app
|
||||
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
|
||||
let deadline = server.app.pending_host_appearance_deadline.unwrap();
|
||||
assert!(
|
||||
server
|
||||
.app
|
||||
.next_headless_loop_deadline_with_git_refresh(Instant::now(), false, false)
|
||||
.unwrap()
|
||||
<= deadline
|
||||
);
|
||||
server.handle_scheduled_tasks_headless(deadline, false);
|
||||
assert!(server.app.pending_host_appearance_deadline.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() {
|
||||
let mut server = test_headless_server();
|
||||
|
||||
@@ -657,8 +657,8 @@ impl TerminalRuntime {
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
|
||||
self.0.test_process_pty_bytes(bytes);
|
||||
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
|
||||
self.0.test_process_pty_bytes(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn test_with_scrollback_bytes(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user