From 273bc2fe0c1984db230910520e815a5800d855f5 Mon Sep 17 00:00:00 2001 From: phygerr <42068889+phygerr@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:07:25 +0800 Subject: [PATCH] =?UTF-8?q?feature=EF=BC=9A1=E3=80=81=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=96=AD=E5=BC=80=E9=87=8D=E8=BF=9E=E5=8A=9F=E8=83=BD=EF=BC=9B?= =?UTF-8?q?2=E3=80=81=E6=94=AF=E6=8C=81=E6=9B=B4=E5=A4=9A=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E5=85=B3=E9=94=AE=E8=AF=8D=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.yml | 2 + locales/zh-CN.yml | 2 + src/app/dialogs.rs | 4 +- src/app/mod.rs | 111 ++------ src/app/ui.rs | 159 ++++------- src/main.rs | 2 +- src/session/mod.rs | 160 ++++++----- src/terminal/custom_blocks.rs | 2 - src/terminal/highlight.rs | 492 ++++++++++++++++++++++++++-------- src/terminal/input.rs | 36 ++- src/terminal/mod.rs | 62 +++-- 11 files changed, 612 insertions(+), 420 deletions(-) diff --git a/locales/en.yml b/locales/en.yml index 90e5459..b0741ac 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -87,6 +87,8 @@ local_terminal_opened: "local terminal opened" connecting: "Connecting" starting_connection: "starting connection..." connection_failed: "Connection Failed" +session_disconnected: "Session disconnected: %{reason}" +press_enter_to_reconnect: "Press Enter to reconnect" preview_failed: "Preview failed: %{err}" downloading_file: "Downloading %{base}..." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 148ae3b..6840769 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -88,6 +88,8 @@ local_terminal_opened: "已打开本地终端" connecting: "正在连接" starting_connection: "开始连接..." connection_failed: "连接失败" +session_disconnected: "会话已断开: %{reason}" +press_enter_to_reconnect: "按 Enter 重新连接" preview_failed: "预览失败: %{err}" downloading_file: "正在下载 %{base}..." diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index db3c656..d2cc5b8 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -2105,7 +2105,7 @@ impl Ashell { this.active_dialog = None; // Send Close command to abort connection if they close the dialog without OK if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_close) { - tab.backend.send(crate::terminal::BackendCommand::Close); + tab.send_backend(crate::terminal::BackendCommand::Close); } cx.notify(); }); @@ -2118,7 +2118,7 @@ impl Ashell { responses.push(state.read(cx).text().to_string()); } if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_ok) { - tab.backend.send(crate::terminal::BackendCommand::PromptResponse(responses)); + tab.send_backend(crate::terminal::BackendCommand::PromptResponse(responses)); } cx.notify(); }); diff --git a/src/app/mod.rs b/src/app/mod.rs index cfd32e2..a9cdc11 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -259,8 +259,6 @@ pub(crate) struct Ashell { pub(crate) tabs_scroll_handle: gpui::ScrollHandle, pub(crate) selector_scroll_handle: gpui::ScrollHandle, pub(crate) saved_scroll_handle: gpui::ScrollHandle, - pub(crate) connection_scroll_handle: gpui::ScrollHandle, - pub(crate) connection_progress: Option, pub(crate) pending_sftp_path_sync: Option, pub(crate) sftp_context_menu: Option, pub(crate) sftp_creating_folder: bool, @@ -327,14 +325,6 @@ pub(crate) enum SelectorEntry { Saved(String), } -#[derive(Clone)] -pub(crate) struct ConnectionProgress { - pub(crate) tab_id: String, - pub(crate) title: SharedString, - pub(crate) lines: Vec, - pub(crate) failed: bool, -} - #[derive(Clone)] pub(crate) struct SftpContextMenuState { pub(crate) remote_path: String, @@ -615,8 +605,6 @@ impl Ashell { tabs_scroll_handle: gpui::ScrollHandle::new(), selector_scroll_handle: gpui::ScrollHandle::new(), saved_scroll_handle: gpui::ScrollHandle::new(), - connection_scroll_handle: gpui::ScrollHandle::new(), - connection_progress: None, pending_sftp_path_sync: Some("/".into()), sftp_context_menu: None, sftp_creating_folder: false, @@ -800,36 +788,25 @@ impl Ashell { match event { BackendEvent::Output { tab_id, bytes } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.backend_initialized = true; tab.feed(&bytes); } } BackendEvent::Status { tab_id, text } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.backend_initialized = true; tab.status = text.clone(); } - if let Some(progress) = self.connection_progress.as_mut() { - if progress.tab_id == tab_id { - progress.lines.push(text.clone().into()); - let _idx = progress.lines.len().saturating_sub(1); - self.connection_scroll_handle - .set_offset(point(px(0.), px(-99999.0))); - } - } self.status = text.into(); } BackendEvent::Connected { tab_id } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.backend_initialized = true; tab.connected = true; + tab.disconnected_reason = None; } self.sync_system_tab_to_active_group(); self.request_active_system_snapshot(); - if self - .connection_progress - .as_ref() - .is_some_and(|progress| progress.tab_id == tab_id && !progress.failed) - { - self.connection_progress = None; - } } BackendEvent::PromptRequest { tab_id, @@ -905,73 +882,29 @@ impl Ashell { } BackendEvent::Closed { tab_id, reason } => { self.remote_sample_in_flight = false; - let mut tab_title = None; - let mut session_label = None; + let is_stale = self + .tabs + .iter() + .find(|t| t.id == tab_id) + .is_some_and(|tab| { + // After retry_disconnected_tab, the old backend's threads + // may still send Closed events. Skip those — they arrive + // before the new backend sends its first Output/Connected. + // Once backend_initialized is set, any Closed is from the + // current backend and should be processed. + tab.backend_generation > 0 && !tab.backend_initialized + }); + if is_stale { + continue; + } if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { tab.connected = false; tab.status = reason.clone(); - tab_title = Some(tab.title.clone()); - session_label = tab.session.as_ref().map(|session| { - format!("{}@{}:{}", session.user, session.host, session.port) - }); - } - let is_sftp = self.tab_groups.iter().any(|g| g.id == tab_id); - if is_sftp { - tab_title = self.tab_groups.iter().find(|g| g.id == tab_id).map(|g| g.title.clone()); - session_label = Some("sftp".to_string()); + tab.disconnected_reason = Some(reason.clone()); } if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { self.system_status = Some(reason.clone().into()); } - let is_graceful_exit = - reason == "local shell closed" || reason == "ssh session closed"; - // Auto-close the pane on graceful exit (e.g. user typed exit) - if is_graceful_exit { - self.handle_tab_close(tab_id.clone()); - self.status = reason.into(); - self.remote_sample_in_flight = false; - return changed; - } - let mut needs_new_progress = false; - if let Some(progress) = self.connection_progress.as_mut() { - if progress.tab_id == tab_id { - progress.lines.push(reason.clone().into()); - let _idx = progress.lines.len().saturating_sub(1); - self.connection_scroll_handle - .set_offset(point(px(0.), px(-99999.0))); - let _ = session_label; - let _ = tab_title; - progress.title = t!("connection_failed").into(); - progress.failed = true; - } else if !progress.failed { - // We were showing connecting progress, but another tab dropped! - // Switch to failed state so the user can see it and retry. - progress.tab_id = tab_id.clone(); - let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); - progress.lines.push(msg.into()); - self.connection_scroll_handle - .set_offset(point(px(0.), px(-99999.0))); - progress.title = t!("connection_failed").into(); - progress.failed = true; - } else { - // Already showing a failure dialog, just append the new failure - let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); - progress.lines.push(msg.into()); - self.connection_scroll_handle - .set_offset(point(px(0.), px(-99999.0))); - } - } else if let Some(_) = session_label { - needs_new_progress = true; - } - - if needs_new_progress && !is_graceful_exit { - self.connection_progress = Some(ConnectionProgress { - tab_id: tab_id.clone(), - title: t!("connection_failed").into(), - lines: vec![reason.clone().into()], - failed: true, - }); - } self.status = reason.into(); } BackendEvent::TransferProgress { @@ -1114,7 +1047,9 @@ impl Ashell { return; } self.remote_sample_in_flight = true; - backend.send(crate::terminal::BackendCommand::SampleMetrics); + if let Ok(backend) = backend.lock() { + backend.send(crate::terminal::BackendCommand::SampleMetrics); + } } pub(crate) fn terminal_ime_bounds_for_range( diff --git a/src/app/ui.rs b/src/app/ui.rs index f319493..a9f858d 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -2365,6 +2365,56 @@ impl Ashell { )); let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default(); el = el.vertical_scrollbar(scrollbar); + + // When disconnected, overlay a reconnect bar at the bottom of the terminal. + // Uses absolute positioning so the terminal element itself is unchanged, + // keeping panel size stable in multi-panel layouts. + let disconnected_reason = this + .tabs + .iter() + .find(|t| t.id == *tab_id) + .and_then(|tab| tab.disconnected_reason.clone()); + if let Some(reason) = disconnected_reason { + let tab_id_for_reconnect = tab_id.clone(); + el = div() + .size_full() + .relative() + .child(el) + .child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .px_3() + .py_1() + .bg(cx.theme().danger.opacity(0.15)) + .child( + div() + .text_size(rems(0.85)) + .text_color(cx.theme().danger) + .child(t!("session_disconnected", "reason" = reason).to_string()), + ) + .child( + div() + .text_size(rems(0.85)) + .text_color(cx.theme().muted_foreground) + .child(format!("— {}", t!("press_enter_to_reconnect"))), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.retry_disconnected_tab(&tab_id_for_reconnect, cx); + }), + ), + ), + ); + } let indicator_color = this .tabs .iter() @@ -2853,115 +2903,6 @@ impl Render for Ashell { ), ) }) - .when_some(self.connection_progress.clone(), |this, progress| { - this.child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .bg(Hsla { - h: 0.0, - s: 0.0, - l: 0.0, - a: 0.48, - }) - .flex() - .items_center() - .justify_center() - .child( - div() - .w(px(420.)) - .p_5() - .rounded_lg() - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().popover) - .shadow_lg() - .child( - v_flex() - .gap_4() - .child( - Button::new("ssh-connect-progress") - .primary() - .loading(!progress.failed) - .label(progress.title.clone()), - ) - .child( - div() - .relative() - .min_h(px(0.)) - .max_h(px(220.)) - .child( - div() - .id("connection-progress-scroll") - .max_h(px(220.)) - .overflow_hidden() - .overflow_y_scroll() - .track_scroll(&self.connection_scroll_handle) - .child( - v_flex().gap_2().children( - progress.lines.iter().cloned().map(|line| { - div() - .text_size(rems(1.0)) - .text_color(if progress.failed { - cx.theme().danger - } else { - cx.theme().muted_foreground - }) - .child(line) - }), - ), - ) - ) - .child( - div() - .absolute() - .top_0() - .right_0() - .bottom_0() - .w(px(16.)) - .child( - Scrollbar::vertical(&self.connection_scroll_handle) - .scrollbar_show(ScrollbarShow::Scrolling) - ) - ) - ) - .when(progress.failed, |this| { - this.child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("ssh-connect-progress-retry") - .primary() - .label(t!("retry").to_string()) - .on_click(cx.listener( - |this, _, _, cx| { - this.retry_connection_progress( - cx, - ) - }, - )), - ) - .child( - Button::new("ssh-connect-progress-close") - .label(t!("cancel").to_string()) - .on_click(cx.listener( - |this, _, _, cx| { - this.cancel_connection_progress( - cx, - ) - }, - )), - ), - ) - }), - ), - ), - ) - }) .on_prepaint({ let view = cx.entity().clone(); move |_, window, cx| { diff --git a/src/main.rs b/src/main.rs index 30e76c8..a7aa815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,7 @@ pub(crate) use app::keybinding_recorder::{ }; pub(crate) use app::{ - Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup, + Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup, }; fn main() { diff --git a/src/session/mod.rs b/src/session/mod.rs index 6036011..7eeaa7b 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use self::config::{AuthMethod, Session}; use crate::{ - Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup, + Ashell, PaneLayout, SelectorEntry, TabGroup, app::constants::{ DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y, @@ -533,12 +533,6 @@ impl Ashell { self.sftp_handles.insert(group_id.clone(), sftp_handle); self.active_tab = Some(id.clone()); self.pending_sftp_path_sync = Some("/".into()); - self.connection_progress = Some(ConnectionProgress { - tab_id: id, - title: t!("connecting").into(), - lines: vec![t!("starting_connection").into()], - failed: false, - }); self.status = "ssh tab opened".into(); cx.notify(); } @@ -552,60 +546,57 @@ impl Ashell { cx.notify(); } - pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context) { - let Some(progress) = self.connection_progress.clone() else { + /// Retry a single disconnected tab by its ID. + /// For SSH tabs: spawns a new SSH connection and restarts SFTP. + /// For local tabs: spawns a new local shell. + /// + /// The existing `TerminalTab` (including its `term` scrollback history) + /// is preserved — only the backend is swapped via `set_backend()`. + pub(crate) fn retry_disconnected_tab(&mut self, tab_id: &str, cx: &mut Context) { + let Some(ix) = self.tabs.iter().position(|t| t.id == tab_id) else { return; }; - self.connection_progress = None; - let mut groups_to_restart_sftp = std::collections::HashSet::new(); - if self.tab_groups.iter().any(|g| g.id == progress.tab_id) { - groups_to_restart_sftp.insert(progress.tab_id.clone()); - } - - let mut retry_tabs = Vec::new(); - for (ix, tab) in self.tabs.iter().enumerate() { - if !tab.connected && tab.session.is_some() { - retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap())); - } - } - - if retry_tabs.is_empty() && groups_to_restart_sftp.is_empty() { - cx.notify(); + if self.tabs[ix].connected || self.tabs[ix].disconnected_reason.is_none() { return; } - for (ix, tab_id, session) in retry_tabs { - // Close old backend - self.tabs[ix].backend.send(BackendCommand::Close); + let is_ssh = self.tabs[ix].session.is_some(); + let session = self.tabs[ix].session.clone(); + let new_generation = self.tabs[ix].backend_generation + 1; + let cols = self.tabs[ix].cols; + let rows = self.tabs[ix].rows; - // Spawn new backend + // Close old backend (sends Close through the shared Arc) + self.tabs[ix].send_backend(BackendCommand::Close); + + if let Some(session) = session { + // SSH tab: spawn new SSH connection let backend = ssh::spawn_ssh_terminal( self.runtime.handle(), - tab_id.clone(), + tab_id.to_string(), session.clone(), - DEFAULT_COLS, - DEFAULT_ROWS, + cols, + rows, self.events_tx.clone(), ); - // Replace tab state in-place to reuse the UI component - self.tabs[ix] = - TerminalTab::new_ssh(tab_id.clone(), &session, backend, self.events_tx.clone()); + // Swap the backend — the Term's internal listener shares the + // same Arc>, so user input is automatically + // routed to the new backend. Terminal history is preserved. + self.tabs[ix].set_backend(backend); + self.tabs[ix].connected = false; + self.tabs[ix].status = "connecting".into(); + self.tabs[ix].disconnected_reason = None; + self.tabs[ix].backend_generation = new_generation; + self.tabs[ix].backend_initialized = false; - // Find group to restart SFTP + // Restart SFTP for the group containing this tab if let Some(group) = self .tab_groups .iter() - .find(|g| g.pane_root.contains(&tab_id)) + .find(|g| g.pane_root.contains(tab_id)) { - groups_to_restart_sftp.insert(group.id.clone()); - } - } - - // Restart SFTP for affected groups - for group_id in groups_to_restart_sftp { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { - // Use the session of any tab in that group + let group_id = group.id.clone(); let group_session = self .tabs .iter() @@ -613,58 +604,57 @@ impl Ashell { .and_then(|t| t.session.clone()); if let Some(session) = group_session { - if let Some(old_handle) = self.sftp_handles.remove(&group.id) { + if let Some(old_handle) = self.sftp_handles.remove(&group_id) { old_handle.close(); } let sftp_handle = crate::sftp::spawn_sftp( self.runtime.handle(), - group.id.clone(), + group_id.clone(), session, self.events_tx.clone(), ); - self.sftp_handles.insert(group.id.clone(), sftp_handle); + self.sftp_handles.insert(group_id.clone(), sftp_handle); - if let Some(sftp) = group.sftp.as_mut() { - sftp.status = rust_i18n::t!("sftp_connecting").to_string(); + if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { + if let Some(sftp) = group.sftp.as_mut() { + sftp.status = rust_i18n::t!("sftp_connecting").to_string(); + } } } } + } else { + // Local tab: spawn new local shell + match local::spawn_local_terminal( + tab_id.to_string(), + cols, + rows, + self.events_tx.clone(), + ) { + Ok(backend) => { + // Swap the backend — preserves terminal history. + self.tabs[ix].set_backend(backend); + self.tabs[ix].connected = true; + self.tabs[ix].status = "local shell".into(); + self.tabs[ix].disconnected_reason = None; + self.tabs[ix].backend_generation = new_generation; + self.tabs[ix].backend_initialized = false; + // Resize the new PTY to match the pane dimensions. + self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows }); + } + Err(err) => { + self.status = format!("failed to reopen local terminal: {err:#}").into(); + cx.notify(); + return; + } + } } - self.connection_progress = Some(ConnectionProgress { - tab_id: progress.tab_id.clone(), - title: t!("connecting").into(), - lines: vec![t!("starting_connection").into()], - failed: false, - }); - self.status = "ssh tabs retrying".into(); - cx.notify(); - } - - pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context) { - let Some(progress) = self.connection_progress.clone() else { - return; - }; - self.connection_progress = None; - - if self.tab_groups.iter().any(|g| g.id == progress.tab_id) { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == progress.tab_id) { - group.sftp = None; - } - self.sftp_handles.remove(&progress.tab_id); - cx.notify(); - return; - } - - let tabs_to_close: Vec<_> = self - .tabs - .iter() - .filter(|tab| !tab.connected && tab.session.is_some()) - .map(|tab| tab.id.clone()) - .collect(); - for id in tabs_to_close { - self.handle_tab_close(id); + self.status = if is_ssh { + "ssh tab retrying" + } else { + "local tab reopened" } + .into(); cx.notify(); } @@ -728,7 +718,7 @@ impl Ashell { id ); if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) { - self.tabs[ix].backend.send(BackendCommand::Close); + self.tabs[ix].send_backend(BackendCommand::Close); self.tabs.remove(ix); } return; @@ -787,7 +777,7 @@ impl Ashell { .collect(); for tab_id in &tab_ids { if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) { - self.tabs[ix].backend.send(BackendCommand::Close); + self.tabs[ix].send_backend(BackendCommand::Close); self.tabs.retain(|t| t.id != *tab_id); } } @@ -799,7 +789,7 @@ impl Ashell { } else { // Just remove this tab from the group if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) { - self.tabs[ix].backend.send(BackendCommand::Close); + self.tabs[ix].send_backend(BackendCommand::Close); self.tabs.retain(|t| t.id != id); } if let Some(g) = self diff --git a/src/terminal/custom_blocks.rs b/src/terminal/custom_blocks.rs index 8e82bca..1668c97 100644 --- a/src/terminal/custom_blocks.rs +++ b/src/terminal/custom_blocks.rs @@ -335,5 +335,3 @@ pub fn paint_custom_block( painted } - - diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 61a11e9..8588773 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -32,15 +32,59 @@ impl HslaExt for Hsla { #[derive(Debug, Clone)] struct HighlightColors { - error: Hsla, - success: Hsla, - warning: Hsla, - info: Hsla, - failure: Hsla, - network: Hsla, - url: Hsla, - port: Hsla, - debug: Hsla, + // Log levels + error: Hsla, // ERROR, ERR + critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL + warning: Hsla, // WARNING, WARN + info: Hsla, // INFO, NOTICE + debug: Hsla, // DEBUG, TRACE, DBG + alert: Hsla, // ALERT + + // Status indicators + success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED + failure: Hsla, // FAILED, FAIL, FAILURE + pending: Hsla, // PENDING, WAITING, PROCESSING + running: Hsla, // RUNNING, ACTIVE, EXECUTING + stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE + skipped: Hsla, // SKIPPED, SKIP + + // Network + network_up: Hsla, // UP, ONLINE, CONNECTED + network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE + timeout: Hsla, // TIMEOUT, TIMED OUT + refused: Hsla, // REFUSED, REJECTED, DENIED + + // Security & Auth + security: Hsla, // SSH, SSL, TLS, CERTIFICATE + auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN + danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET + + // Operations + started: Hsla, // START, BOOT, STARTING + stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING + restart: Hsla, // RESTART, RESTARTING + deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT + crashed: Hsla, // CRASH, CRASHED, SIGSEGV + + // Resources + memory: Hsla, // MEMORY, RAM, SWAP, HEAP + cpu: Hsla, // CPU, PROCESSOR, CORE + disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT + + // HTTP codes + http_2xx: Hsla, // 200-299 Success + http_3xx: Hsla, // 300-399 Redirect + http_4xx: Hsla, // 400-499 Client Error + http_5xx: Hsla, // 500-599 Server Error + + // Dev / Exceptions + exception: Hsla, // Exception, Traceback, Error type + deprecated: Hsla, // DEPRECATED, TODO, FIXME + + // Existing + network: Hsla, // IP addresses + url: Hsla, // http://, https:// + port: Hsla, // :22, :443, etc. } fn hsla(r: u8, g: u8, b: u8) -> Hsla { @@ -55,20 +99,154 @@ fn hsla(r: u8, g: u8, b: u8) -> Hsla { fn highlight_colors() -> HighlightColors { HighlightColors { - error: hsla(224, 96, 96), // #E06060 red - success: hsla(126, 198, 153), // #7EC699 green - warning: hsla(232, 201, 122), // #E8C97A yellow - info: hsla(108, 180, 238), // #6CB4EE blue - failure: hsla(232, 168, 124), // #E8A87C orange - network: hsla(199, 146, 234), // #C792EA purple - url: hsla( 86, 212, 199), // #56D4C7 teal - port: hsla(130, 170, 200), // #82AAC8 muted teal - debug: hsla(130, 140, 155), // #828C9B gray + // Log levels + error: hsla(224, 96, 96), // #E06060 red + critical: hsla(255, 50, 50), // #FF3232 bright red + warning: hsla(232, 201, 122), // #E8C97A yellow + info: hsla(108, 180, 238), // #6CB4EE blue + debug: hsla(130, 140, 155), // #828C9B gray + alert: hsla(213, 126, 234), // #D57EEA bright magenta + + // Status + success: hsla(126, 198, 153), // #7EC699 green + failure: hsla(232, 168, 124), // #E8A87C orange + pending: hsla(232, 201, 122), // #E8C97A yellow + running: hsla( 86, 206, 234), // #56CEEA cyan + stopped: hsla(160, 165, 175), // #A0A5AF gray + skipped: hsla(199, 146, 234), // #C792EA purple + + // Network + network_up: hsla(126, 198, 153), // #7EC699 green + network_down: hsla(224, 96, 96), // #E06060 red + timeout: hsla(213, 126, 234), // #D57EEA magenta + refused: hsla(245, 160, 80), // #F5A050 orange + + // Security + security: hsla( 86, 206, 234), // #56CEEA cyan + auth: hsla(126, 198, 153), // #7EC699 green + danger: hsla(180, 50, 50), // #B43232 dark red + + // Operations + started: hsla(126, 198, 153), // #7EC699 green + stopped_op: hsla(224, 96, 96), // #E06060 red + restart: hsla(232, 201, 122), // #E8C97A yellow + deploy: hsla(100, 210, 140), // #64D28C bright green + crashed: hsla(255, 50, 50), // #FF3232 bright red + + // Resources + memory: hsla(199, 146, 234), // #C792EA purple + cpu: hsla( 86, 206, 234), // #56CEEA cyan + disk: hsla(108, 180, 238), // #6CB4EE blue + + // HTTP codes + http_2xx: hsla(126, 198, 153), // #7EC699 green + http_3xx: hsla( 86, 206, 234), // #56CEEA cyan + http_4xx: hsla(232, 201, 122), // #E8C97A yellow + http_5xx: hsla(224, 96, 96), // #E06060 red + + // Dev + exception: hsla(224, 96, 96), // #E06060 red + deprecated: hsla(245, 160, 80), // #F5A050 orange + + // Existing + network: hsla(199, 146, 234), // #C792EA purple + url: hsla( 86, 212, 199), // #56D4C7 teal + port: hsla(130, 170, 200), // #82AAC8 muted teal } } -fn is_boundary(c: char) -> bool { - !c.is_ascii_alphanumeric() && c != '_' +/// Highlight all occurrences of keyword list in `text`, writing to `map`. +/// Case-insensitive, matches inside larger words (e.g. "my_ERROR" highlights "ERROR"). +/// Each keyword only matches once per position (no overlapping highlights). +fn highlight_keywords( + map: &mut HashMap<(i32, i32), Hsla>, + text: &str, + byte_to_col: &[i32], + row_i32: i32, + keywords: &[&str], + color: Hsla, +) { + for &kw in keywords { + let kw_lower: Vec = kw.bytes().map(|b| b.to_ascii_lowercase()).collect(); + let text_bytes = text.as_bytes(); + let text_lower: Vec = text_bytes.iter().map(|b| b.to_ascii_lowercase()).collect(); + let mut start = 0; + while start + kw_lower.len() <= text_lower.len() { + if text_lower[start..].starts_with(&kw_lower) { + let abs = start; + let start_col = byte_to_col[abs]; + let end_col = byte_to_col[(abs + kw.len() - 1).min(byte_to_col.len() - 1)]; + for c in start_col..=end_col { + map.entry((row_i32, c)).or_insert(color); + } + start = abs + kw.len(); + } else { + start += 1; + } + } + } +} + +/// Highlight HTTP status codes (200, 301, 404, 500, etc.) +/// Only matches specific common HTTP codes, not all 3-digit numbers. +fn highlight_http_codes( + map: &mut HashMap<(i32, i32), Hsla>, + text: &str, + byte_to_col: &[i32], + row_i32: i32, + colors: &HighlightColors, +) { + let bytes = text.as_bytes(); + let len = bytes.len(); + + // Specific HTTP codes to highlight + const HTTP_CODES: &[(u16, bool)] = &[ + // 2xx + (200, true), (201, true), (202, true), (204, true), (206, true), + // 3xx + (301, true), (302, true), (304, true), (307, true), (308, true), + // 4xx + (400, true), (401, true), (403, true), (404, true), (405, true), + (408, true), (409, true), (410, true), (422, true), (429, true), + // 5xx + (500, true), (502, true), (503, true), (504, true), + ]; + + for i in 0..len.saturating_sub(2) { + if !bytes[i].is_ascii_digit() || !bytes[i + 1].is_ascii_digit() || !bytes[i + 2].is_ascii_digit() { + continue; + } + + let code: u16 = ((bytes[i] - b'0') as u16) * 100 + + ((bytes[i + 1] - b'0') as u16) * 10 + + ((bytes[i + 2] - b'0') as u16); + + // Only match specific codes + if !HTTP_CODES.iter().any(|&(c, _)| c == code) { + continue; + } + + // Must be at a boundary (not part of a longer number) + let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); + let after_ok = i + 3 >= len || !bytes[i + 3].is_ascii_digit(); + if !before_ok || !after_ok { + continue; + } + + let color = match code { + 200..=299 => colors.http_2xx, + 300..=399 => colors.http_3xx, + 400..=499 => colors.http_4xx, + 500..=599 => colors.http_5xx, + _ => continue, + }; + + let start_col = byte_to_col[i]; + let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)]; + for c in start_col..=end_col { + map.entry((row_i32, c)).or_insert(color); + } + } } pub fn highlight_cells( @@ -77,7 +255,6 @@ pub fn highlight_cells( ) -> HashMap<(i32, i32), Hsla> { let colors = highlight_colors(); - // Pre-allocate the outer vector to the size of rows. let mut row_chars: Vec> = vec![Vec::with_capacity(128); rows]; for rc in cells { if rc.row < 0 || (rc.row as usize) >= rows { @@ -91,7 +268,6 @@ pub fn highlight_cells( let mut map = HashMap::new(); - // Reusable buffers to avoid allocation inside the loop let mut chars_buf = String::with_capacity(128); let mut byte_to_col: Vec = Vec::with_capacity(128); @@ -112,96 +288,210 @@ pub fn highlight_cells( } let text = chars_buf.as_str(); - // ── 1. Error keywords ────────────────────────── - for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.error); - } - } - } + // ── 1. Critical errors (highest priority) ────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["PANIC", "EMERGENCY", "FATAL", "SEGFAULT", "CRITICAL", + "OOM", "OUT OF MEMORY", "KERNEL PANIC", "CORE DUMPED", "BUS ERROR"], + colors.critical, + ); - // ── 2. Success keywords ─────────────────────────── - for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.success); - } - } - } + // ── 2. Error keywords ────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["ERROR", "ERR"], colors.error); - // ── 3. Failure keywords ─────────────────────────── - for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.failure); - } - } - } + // ── 3. Alert ─────────────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["ALERT"], colors.alert); - // ── 4. Warning keywords ─────────────────────────── - for kw in &["WARNING", "WARN"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.warning); - } - } - } + // ── 4. Warning keywords ──────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["WARNING", "WARN"], colors.warning); - // ── 5. Info keywords ────────────────────────────── - for kw in &["NOTICE", "INFO"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.info); - } - } - } + // ── 5. Info keywords ─────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["INFO", "INFORMATION", "NOTICE"], colors.info); - // ── 6. Debug keywords ───────────────────────────── - for kw in &["DEBUG", "DBG", "TRACE"] { - for m in find_keyword(text, kw) { - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.debug); - } - } - } + // ── 6. Debug keywords ────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["DEBUG", "DBG", "TRACE"], colors.debug); - // ── 7. IP addresses ─────────────────────────────── + // ── 7. Success status ────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["SUCCESS", "SUCCEEDED", "SUCCESSFUL", "PASSED", "PASS", + "OK", "DONE", "COMPLETED", "FINISHED", "COMPLETE"], + colors.success, + ); + + // ── 8. Failure status ────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["FAILED", "FAILURE", "FAIL", "NOT OK"], + colors.failure, + ); + + // ── 9. Pending / Waiting ─────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["PENDING", "WAITING", "PROCESSING", "IN PROGRESS", "QUEUED"], + colors.pending, + ); + + // ── 10. Running / Active ─────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["RUNNING", "ACTIVE", "EXECUTING", "IN_PROGRESS", "LIVE"], + colors.running, + ); + + // ── 11. Stopped / Inactive ───────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["STOPPED", "INACTIVE", "HALTED", "IDLE", "PAUSED"], + colors.stopped, + ); + + // ── 12. Skipped ──────────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["SKIPPED", "SKIP", "SKIPPING"], colors.skipped); + + // ── 13. Network UP ───────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["UP", "ONLINE", "CONNECTED", "REACHABLE", "LISTENING", + "ESTABLISHED", "LINK UP"], + colors.network_up, + ); + + // ── 14. Network DOWN ─────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["DOWN", "OFFLINE", "UNREACHABLE", "DISCONNECTED", + "NOT LISTENING", "LINK DOWN", "NO CARRIER"], + colors.network_down, + ); + + // ── 15. Timeout ──────────────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["TIMEOUT", "TIMED OUT", "TIMEOUTS", "ETIMEDOUT", "SLOW", "LATENCY"], + colors.timeout, + ); + + // ── 16. Refused / Denied ─────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["REFUSED", "REJECTED", "DENIED", "PERMISSION DENIED", + "ACCESS DENIED", "FORBIDDEN", "BLOCKED", "DROP"], + colors.refused, + ); + + // ── 17. Security / Protocol ──────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["SSH", "SSHD", "SSL", "TLS", "HTTPS", "CERTIFICATE", "CERT", + "FIREWALL", "IPTABLES", "ACL", "WAF"], + colors.security, + ); + + // ── 18. Authentication ───────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["AUTHENTICATED", "ACCEPTED", "AUTHORIZED", "LOGIN", "LOGOUT", + "LOGGED IN", "LOGGED OUT", "SESSION"], + colors.auth, + ); + + // ── 19. Danger (root/sudo/secrets) ───────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["ROOT", "SUDO", "UID=0", "PASSWORD", "SECRET", "TOKEN", + "API_KEY", "APIKEY", "PRIVATE KEY", "CREDENTIALS"], + colors.danger, + ); + + // ── 20. Operations: Start ────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH"], + colors.started, + ); + + // ── 21. Operations: Stop ─────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["STOPPED", "STOP", "STOPPING", "SHUTDOWN", "SHUTTING DOWN", "TERMINATED"], + colors.stopped_op, + ); + + // ── 22. Operations: Restart ──────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["RESTARTED", "RESTART", "RESTARTING", "RELOAD", "RELOADED", "RELOADING"], + colors.restart, + ); + + // ── 23. Operations: Deploy ───────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["DEPLOYED", "DEPLOYMENT", "DEPLOYING", "DEPLOY", + "ROLLBACK", "ROLLED BACK", "ROLLING BACK", "UPGRADE", "UPGRADED"], + colors.deploy, + ); + + // ── 24. Operations: Crash ────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["CRASH", "CRASHED", "CRASHING", "SIGSEGV", "SIGABRT", "SIGKILL", + "DIED", "EXITED", "EXIT CODE", "CORE DUMP"], + colors.crashed, + ); + + // ── 25. Resources: Memory ────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["MEMORY", "RAM", "HEAP", "STACK", "SWAP", "MEM"], + colors.memory, + ); + + // ── 26. Resources: CPU ───────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["CPU", "PROCESSOR", "CORE", "CORES", "THREAD", "THREADS", "LOAD"], + colors.cpu, + ); + + // ── 27. Resources: Disk ──────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["DISK", "STORAGE", "PARTITION", "MOUNT", "FILESYSTEM", + "INODE", "IOPS", "READ", "WRITE"], + colors.disk, + ); + + // ── 28. Dev: Exceptions ──────────────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["EXCEPTION", "TRACEBACK", "THROW", "THROWN", "STACKTRACE", + "TYPEERROR", "VALUEERROR", "KEYERROR", "ATTRIBUTEERROR", + "INDEXERROR", "RUNTIMEERROR", "IOERROR", "OSERROR", + "NULLPOINTER", "NPE", "SEGFAULT"], + colors.exception, + ); + + // ── 29. Dev: Deprecated / TODO ───────────────────────── + highlight_keywords(&mut map, text, &byte_to_col, row_i32, + &["DEPRECATED", "TODO", "FIXME", "HACK", "XXX", "WORKAROUND", "TEMPORARY"], + colors.deprecated, + ); + + // ── 30. HTTP status codes ────────────────────────────── + highlight_http_codes(&mut map, text, &byte_to_col, row_i32, &colors); + + // ── 31. IP addresses ─────────────────────────────────── for m in find_ip_addresses(text) { + let ip_len = find_ip_len(&text[m..]); let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)]; + let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)]; for c in start_col..=end_col { map.entry((row_i32, c)).or_insert(colors.network); } } - // ── 8. URLs ─────────────────────────────────────── + // ── 32. URLs ─────────────────────────────────────────── for m in find_urls(text) { let url_len = find_url_len(&text[m..]); let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)]; + let end_col = byte_to_col[(m + url_len - 1).min(byte_to_col.len() - 1)]; for c in start_col..=end_col { map.entry((row_i32, c)).or_insert(colors.url); } } - // ── 9. Port numbers ───────────────────────────────────── + // ── 33. Port numbers ─────────────────────────────────── for m in find_ports(text) { let port_len = find_port_len(&text[m..]); let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)]; + let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)]; for c in start_col..=end_col { map.entry((row_i32, c)).or_insert(colors.port); } @@ -211,26 +501,6 @@ pub fn highlight_cells( map } -fn find_keyword(text: &str, keyword: &str) -> Vec { - let mut positions = Vec::new(); - let mut start = 0; - while let Some(pos) = text[start..].find(keyword) { - let abs = start + pos; - let before_ok = abs == 0 - || text.as_bytes()[abs - 1] == b' ' - || is_boundary(text.as_bytes()[abs - 1] as char); - let after_pos = abs + keyword.len(); - let after_ok = after_pos >= text.len() - || text.as_bytes()[after_pos] == b' ' - || is_boundary(text.as_bytes()[after_pos] as char); - if before_ok && after_ok { - positions.push(abs); - } - start = abs + keyword.len(); - } - positions -} - fn find_ip_len(text: &str) -> usize { let bytes = text.as_bytes(); let mut dots = 0u8; @@ -255,7 +525,7 @@ fn find_ip_len(text: &str) -> usize { } digits = 0; } - _ => break, + _ => break, // Stop at first non-digit/non-dot (including '/') } len += 1; } @@ -292,6 +562,10 @@ fn find_ip_addresses(text: &str) -> Vec { positions } +fn is_boundary(c: char) -> bool { + !c.is_ascii_alphanumeric() && c != '_' +} + fn find_urls(text: &str) -> Vec { let mut positions = Vec::new(); let mut start = 0; diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 2b4d8fd..5ef0f2c 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -112,6 +112,29 @@ impl Ashell { } } + // If the active tab is disconnected and user presses Enter, reconnect + if event.keystroke.key == "enter" + && !event.keystroke.modifiers.shift + && !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + let active_id = self.active_tab.clone(); + if let Some(active_id) = active_id { + let is_disconnected = self + .tabs + .iter() + .find(|t| t.id == active_id) + .is_some_and(|tab| tab.disconnected_reason.is_some()); + if is_disconnected { + self.retry_disconnected_tab(&active_id, cx); + window.prevent_default(); + cx.stop_propagation(); + return; + } + } + } + if event.prefer_character_input { if let Some(text) = event.keystroke.key_char.as_deref() { if !text.is_empty() @@ -138,7 +161,7 @@ impl Ashell { tab.clear_selection(); if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) { - tab.backend.send(BackendCommand::Input(bytes)); + tab.send_backend(BackendCommand::Input(bytes)); window.prevent_default(); cx.stop_propagation(); cx.notify(); @@ -176,7 +199,7 @@ impl Ashell { } tab.clear_selection(); - tab.backend.send(BackendCommand::Input(bytes)); + tab.send_backend(BackendCommand::Input(bytes)); window.prevent_default(); cx.stop_propagation(); cx.notify(); @@ -258,8 +281,7 @@ impl Ashell { } tab.clear_selection(); self.terminal_marked_text = None; - tab.backend - .send(BackendCommand::Input(text.as_bytes().to_vec())); + tab.send_backend(BackendCommand::Input(text.as_bytes().to_vec())); window.invalidate_character_coordinates(); cx.notify(); } @@ -517,8 +539,7 @@ impl Ashell { } } if !bytes.is_empty() { - tab.backend - .send(crate::terminal::BackendCommand::Input(bytes)); + tab.send_backend(crate::terminal::BackendCommand::Input(bytes)); } } window.prevent_default(); @@ -532,8 +553,7 @@ impl Ashell { bytes.extend_from_slice(&[b'\x1b', b'O', code]); } if !bytes.is_empty() { - tab.backend - .send(crate::terminal::BackendCommand::Input(bytes)); + tab.send_backend(crate::terminal::BackendCommand::Input(bytes)); } window.prevent_default(); cx.stop_propagation(); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0ee0483..bff233c 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -138,12 +138,20 @@ pub struct TerminalTab { pub kind: TabKind, pub status: String, pub connected: bool, + pub disconnected_reason: Option, + /// Incremented each time the tab is reconnected. Used to ignore stale + /// `BackendEvent::Closed` from the previous backend after a retry. + pub backend_generation: u32, + /// Set to `true` when the current backend sends its first `Output` or + /// `Connected` event. Used to skip stale `Closed` events that arrive + /// before the new backend has started producing output. + pub backend_initialized: bool, pub session: Option, processor: Processor, term: Term, - cols: u16, - rows: u16, - pub backend: BackendTx, + pub cols: u16, + pub rows: u16, + pub backend: std::sync::Arc>, pub scroll_pixel_y: f32, pub(crate) highlight_cache: std::cell::RefCell, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>, } @@ -241,18 +249,22 @@ impl TerminalTab { backend: BackendTx, events: std::sync::mpsc::Sender, ) -> Self { + let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend)); Self { id: id.clone(), title, kind, status, connected: matches!(kind, TabKind::Local), + disconnected_reason: None, + backend_generation: 0, + backend_initialized: true, session: None, processor: Processor::new(), - term: new_term(100, 30, backend.clone(), id, events), + term: new_term(100, 30, shared_backend.clone(), id, events), cols: 100, rows: 30, - backend, + backend: shared_backend, scroll_pixel_y: 0.0, highlight_cache: std::cell::RefCell::new(None), } @@ -262,6 +274,22 @@ impl TerminalTab { self.processor.advance(&mut self.term, bytes); } + /// Send a command to the backend. Thread-safe via the shared Arc. + pub fn send_backend(&self, command: BackendCommand) { + if let Ok(backend) = self.backend.lock() { + backend.send(command); + } + } + + /// Replace the backend with a new one. The `Term`'s internal listener + /// shares the same `Arc`, so user input is automatically routed to the + /// new backend. The old backend must be closed by the caller. + pub fn set_backend(&self, new_backend: BackendTx) { + if let Ok(mut backend) = self.backend.lock() { + *backend = new_backend; + } + } + pub fn resize(&mut self, cols: u16, rows: u16) { let new_cols = cols.max(1); let new_rows = rows.max(1); @@ -274,7 +302,7 @@ impl TerminalTab { self.rows ); self.term.resize(TerminalSize::new(self.cols, self.rows)); - self.backend.send(BackendCommand::Resize { cols, rows }); + self.send_backend(BackendCommand::Resize { cols, rows }); } } @@ -339,7 +367,7 @@ impl TerminalTab { let highlights = if is_enabled { let mut cache = self.highlight_cache.borrow_mut(); - let cache_valid = cache.as_ref().map_or(false, |(cached_cells, _)| { + let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| { cached_cells == &cells }); if cache_valid { @@ -467,8 +495,7 @@ impl TerminalTab { .replace("\r\n", "\r") .replace('\n', "\r"); - self.backend - .send(BackendCommand::Input(paste_text.into_bytes())); + self.send_backend(BackendCommand::Input(paste_text.into_bytes())); } } @@ -518,16 +545,18 @@ fn viewport_selection_from_range( #[derive(Clone)] struct TerminalListener { tab_id: String, - backend: BackendTx, + backend: std::sync::Arc>, events: std::sync::mpsc::Sender, } impl EventListener for TerminalListener { fn send_event(&self, event: Event) { match event { - Event::PtyWrite(output) => self - .backend - .send(BackendCommand::Input(output.into_bytes())), + Event::PtyWrite(output) => { + if let Ok(backend) = self.backend.lock() { + backend.send(BackendCommand::Input(output.into_bytes())); + } + } Event::TextAreaSizeRequest(format) => { let size = alacritty_terminal::event::WindowSize { num_lines: 30, @@ -535,8 +564,9 @@ impl EventListener for TerminalListener { cell_width: 8, cell_height: 16, }; - self.backend - .send(BackendCommand::Input(format(size).into_bytes())); + if let Ok(backend) = self.backend.lock() { + backend.send(BackendCommand::Input(format(size).into_bytes())); + } } Event::Title(title) => { let _ = self.events.send(BackendEvent::TerminalTitleChanged { @@ -552,7 +582,7 @@ impl EventListener for TerminalListener { fn new_term( cols: u16, rows: u16, - backend: BackendTx, + backend: std::sync::Arc>, tab_id: String, events: std::sync::mpsc::Sender, ) -> Term {