From a343f7bc68f0078946a30e5ecc92a18b34d30c2c Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 13:54:19 +0800 Subject: [PATCH 01/15] refactor: unify terminal exit handling --- crates/gpui_term/examples/term.rs | 1 - .../gpui_term/src/backends/alacritty/mod.rs | 115 ++++- crates/gpui_term/src/backends/wezterm/mod.rs | 20 +- crates/gpui_term/src/builder.rs | 8 - crates/gpui_term/src/terminal.rs | 8 + locales/en.yml | 1 + locales/zh-CN.yml | 1 + termua/src/panel/sessions_sidebar/actions.rs | 11 +- termua/src/panel/sessions_sidebar/render.rs | 18 + termua/src/panel/sessions_sidebar/state.rs | 1 + termua/src/panel/sessions_sidebar/tests.rs | 54 +++ termua/src/panel/terminal_panel.rs | 8 + termua/src/window/main_window/actions.rs | 142 ++++++- termua/src/window/main_window/state.rs | 1 - termua/src/window/main_window/tests.rs | 402 +++++++++++++++++- 15 files changed, 723 insertions(+), 68 deletions(-) diff --git a/crates/gpui_term/examples/term.rs b/crates/gpui_term/examples/term.rs index 7202f1a..13e104b 100644 --- a/crates/gpui_term/examples/term.rs +++ b/crates/gpui_term/examples/term.rs @@ -57,7 +57,6 @@ fn main() { }, CursorShape::default(), None, - None, ) .unwrap() .subscribe(cx) diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index 2364943..72f1cbb 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -259,7 +259,6 @@ impl TerminalBuilder { cast_slot: Arc>>, env: HashMap, window_id: u64, - exit_fn: Option)>, ) -> anyhow::Result { // env.entry("LANG".to_string()) // .or_insert_with(|| "en_US.UTF-8".to_string()); @@ -308,7 +307,6 @@ impl TerminalBuilder { cast_slot, pty, pty_options.drain_on_exit, - exit_fn, ) } @@ -321,7 +319,6 @@ impl TerminalBuilder { cast_slot: Arc>>, env: HashMap, opts: SshOptions, - exit_fn: Option)>, ) -> anyhow::Result { // SSH PTY encapsulates its own remote spawning; no `tty::Options`. let (pty, sftp) = ssh::Pty::new(env, opts, Arc::clone(&cast_slot))?; @@ -334,7 +331,6 @@ impl TerminalBuilder { cast_slot, pty, false, - exit_fn, ) } @@ -345,11 +341,10 @@ impl TerminalBuilder { events_rx: UnboundedReceiver, cast_slot: Arc>>, opts: SerialOptions, - exit_fn: Option)>, ) -> anyhow::Result { let pty = serial::Pty::new(opts, Arc::clone(&cast_slot))?; Self::build_with_pty( - term, config, events_tx, events_rx, None, cast_slot, pty, false, exit_fn, + term, config, events_tx, events_rx, None, cast_slot, pty, false, ) } @@ -363,7 +358,6 @@ impl TerminalBuilder { cast_slot: Arc>>, pty: T, drain_on_exit: bool, - exit_fn: Option)>, ) -> anyhow::Result where T: tty::EventedPty + OnResize + Send + 'static, @@ -389,7 +383,7 @@ impl TerminalBuilder { search: SearchState::default(), selection: SelectionState::default(), scroll_px: px(0.0), - exit_fn, + exited: false, content: TerminalContent::default(), sftp, record: RecordState::new(cast_slot), @@ -403,20 +397,19 @@ impl TerminalBuilder { pty_source: PtySource, cursor_shape: CursorShape, max_scroll_history_lines: Option, - exit_fn: Option)>, ) -> anyhow::Result { let (term, config, events_tx, events_rx, cast_slot) = Self::build_shared_term_state(cursor_shape, max_scroll_history_lines); match pty_source { PtySource::Local { env, window_id } => Self::build_local( - term, config, events_tx, events_rx, cast_slot, env, window_id, exit_fn, - ), - PtySource::Ssh { env, opts } => Self::build_ssh( - term, config, events_tx, events_rx, cast_slot, env, opts, exit_fn, + term, config, events_tx, events_rx, cast_slot, env, window_id, ), + PtySource::Ssh { env, opts } => { + Self::build_ssh(term, config, events_tx, events_rx, cast_slot, env, opts) + } PtySource::Serial { opts } => { - Self::build_serial(term, config, events_tx, events_rx, cast_slot, opts, exit_fn) + Self::build_serial(term, config, events_tx, events_rx, cast_slot, opts) } } } @@ -528,7 +521,7 @@ pub struct AlacrittyBackend { selection: SelectionState, scroll_px: Pixels, - exit_fn: Option)>, + exited: bool, content: TerminalContent, // SFTP support for SSH terminals. @@ -674,9 +667,8 @@ impl AlacrittyBackend { } AlacTermEvent::Bell => cx.emit(Event::Bell), AlacTermEvent::Exit => { - if let Some(f) = self.exit_fn.as_ref() { - f(cx); - } + self.exited = true; + cx.emit(Event::CloseTerminal); } AlacTermEvent::Title(_title) => { cx.emit(Event::TitleChanged); @@ -969,6 +961,10 @@ impl TerminalBackend for AlacrittyBackend { None } + fn has_exited(&self) -> bool { + self.exited + } + fn vi_mode_enabled(&self) -> bool { false } @@ -1935,11 +1931,14 @@ mod selection_tests { sync::FairMutex, tty::{ChildEvent, EventedPty, EventedReadWrite}, }; - use gpui::{Bounds, Modifiers, MouseButton, MouseDownEvent, point, px, size}; + use gpui::{AppContext, Bounds, Modifiers, MouseButton, MouseDownEvent, point, px, size}; use parking_lot::Mutex; use polling::{Event, PollMode, Poller}; - use super::{AlacrittyBackend, EventProxy, RecordState, SearchState, SelectionState, TermOp}; + use super::{ + AlacTermEvent, AlacrittyBackend, EventProxy, RecordState, SearchState, SelectionState, + TermOp, + }; use crate::{ TerminalBackend, TerminalBounds, TerminalContent, command_blocks::CommandBlockTracker, }; @@ -1995,6 +1994,80 @@ mod selection_tests { fn on_resize(&mut self, _window_size: WindowSize) {} } + fn test_backend() -> AlacrittyBackend { + let (events_tx, _events_rx) = futures::channel::mpsc::unbounded(); + let term = Term::new( + alacritty_terminal::term::Config::default(), + &TerminalBounds::default(), + EventProxy(events_tx.clone()), + ); + let term = Arc::new(FairMutex::new(term)); + + let event_loop = EventLoop::new( + Arc::clone(&term), + EventProxy(events_tx), + DummyPty::default(), + false, + false, + ) + .expect("event loop"); + let pty_tx = alacritty_terminal::event_loop::Notifier(event_loop.channel()); + + let cast_slot: Arc>> = + Arc::new(Mutex::new(None)); + + AlacrittyBackend { + pty_tx, + term, + term_config: alacritty_terminal::term::Config::default(), + pending_ops: VecDeque::new(), + term_mode: alacritty_terminal::term::TermMode::empty(), + search: SearchState::default(), + selection: SelectionState::default(), + scroll_px: px(0.0), + exited: false, + content: TerminalContent::default(), + sftp: None, + record: RecordState::new(cast_slot), + blocks: CommandBlockTracker::new(200), + } + } + + #[gpui::test] + fn exit_emits_close_terminal_event(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + crate::init(app); + }); + + let terminal = cx.update(|app| { + app.new(|_cx| { + crate::Terminal::new(crate::TerminalType::Alacritty, Box::new(test_backend())) + }) + }); + + let exited = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let exited_for_sub = exited.clone(); + let _sub = cx.update(|app| { + app.subscribe(&terminal, move |_, event: &crate::Event, _| { + if matches!(event, crate::Event::CloseTerminal) { + exited_for_sub.store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + }); + + cx.update(|app| { + terminal.update(app, |terminal, cx| { + terminal.dispatch_backend_event(Box::new(AlacTermEvent::Exit), cx); + }); + }); + cx.run_until_parked(); + + assert!( + exited.load(std::sync::atomic::Ordering::Relaxed), + "expected alacritty exit to emit CloseTerminal" + ); + } + #[test] fn triple_click_selects_current_line() { let (events_tx, _events_rx) = futures::channel::mpsc::unbounded(); @@ -2028,7 +2101,7 @@ mod selection_tests { search: SearchState::default(), selection: SelectionState::default(), scroll_px: px(0.0), - exit_fn: None, + exited: false, content: TerminalContent::default(), sftp: None, record: RecordState::new(cast_slot), diff --git a/crates/gpui_term/src/backends/wezterm/mod.rs b/crates/gpui_term/src/backends/wezterm/mod.rs index 0953ea5..67fad7c 100644 --- a/crates/gpui_term/src/backends/wezterm/mod.rs +++ b/crates/gpui_term/src/backends/wezterm/mod.rs @@ -110,7 +110,6 @@ impl TerminalBuilder { source: PtySource, cursor_shape: CursorShape, max_scrollback: Option, - exit_fn: Option)>, ) -> anyhow::Result { let scrollback_size = max_scrollback .unwrap_or(backends::DEFAULT_SCROLLBACK_LINES) @@ -204,7 +203,7 @@ impl TerminalBuilder { }; let (backend, events_rx) = - build_backend(master, child, scrollback_size, cursor_shape, exit_fn, sftp)?; + build_backend(master, child, scrollback_size, cursor_shape, sftp)?; Ok(Self { backend, events_rx }) } @@ -258,7 +257,6 @@ fn build_backend( child: Box, scrollback_size: usize, default_cursor_shape: CursorShape, - exit_fn: Option)>, sftp: Option, ) -> anyhow::Result<(WezTermBackend, Receiver)> { let reader = master.try_clone_reader()?; @@ -303,7 +301,6 @@ fn build_backend( last_mouse_pos: None, selection: SelectionState::default(), default_cursor_shape, - exit_fn, scroll_px: px(0.0), sftp, record: RecordState::new(cast_slot), @@ -496,7 +493,6 @@ pub struct WezTermBackend { last_mouse_pos: Option>, selection: SelectionState, default_cursor_shape: CursorShape, - exit_fn: Option)>, scroll_px: Pixels, // SFTP support for SSH terminals. @@ -1460,11 +1456,7 @@ impl TerminalBackend for WezTermBackend { // Ensure any active cast recording flushes and closes on PTY exit, even if the // terminal view remains open to show the final buffer. self.stop_cast_recording(); - if let Some(f) = self.exit_fn.as_ref() { - f(cx); - } else { - cx.emit(Event::CloseTerminal); - } + cx.emit(Event::CloseTerminal); } WezEvent::Alert(alert) => match alert { Alert::Bell => cx.emit(Event::Bell), @@ -1650,6 +1642,10 @@ impl TerminalBackend for WezTermBackend { self.last_clicked_line } + fn has_exited(&self) -> bool { + self.exited + } + fn vi_mode_enabled(&self) -> bool { false } @@ -2715,7 +2711,6 @@ mod tests { last_mouse_pos: None, selection: super::SelectionState::default(), default_cursor_shape: crate::CursorShape::default(), - exit_fn: None, scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), @@ -2782,7 +2777,6 @@ mod tests { last_mouse_pos: None, selection: super::SelectionState::default(), default_cursor_shape: crate::CursorShape::default(), - exit_fn: None, scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), @@ -3067,7 +3061,6 @@ mod tests { last_mouse_pos: None, selection: super::SelectionState::default(), default_cursor_shape: crate::CursorShape::default(), - exit_fn: None, scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), @@ -3154,7 +3147,6 @@ mod tests { last_mouse_pos: None, selection: super::SelectionState::default(), default_cursor_shape: crate::CursorShape::default(), - exit_fn: None, scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), diff --git a/crates/gpui_term/src/builder.rs b/crates/gpui_term/src/builder.rs index 4c80f3d..60bb96f 100644 --- a/crates/gpui_term/src/builder.rs +++ b/crates/gpui_term/src/builder.rs @@ -25,14 +25,12 @@ impl TerminalBuilder { cursor_shape: CursorShape, max_scroll_history_lines: Option, window_id: u64, - exit_fn: Option)>, ) -> anyhow::Result { Self::new_with_pty( backend_type, PtySource::Local { env, window_id }, cursor_shape, max_scroll_history_lines, - exit_fn, ) } @@ -41,7 +39,6 @@ impl TerminalBuilder { pty_source: PtySource, cursor_shape: CursorShape, max_scroll_history_lines: Option, - exit_fn: Option)>, ) -> anyhow::Result { let inner = match backend_type { TerminalType::Alacritty => { @@ -49,7 +46,6 @@ impl TerminalBuilder { pty_source, cursor_shape, max_scroll_history_lines, - exit_fn, )?) } TerminalType::WezTerm => { @@ -57,7 +53,6 @@ impl TerminalBuilder { pty_source, cursor_shape, max_scroll_history_lines, - exit_fn, )?) } }; @@ -95,7 +90,6 @@ mod tests { CursorShape::default(), None, 0, - None, ); let _ = TerminalBuilder::new_with_pty( @@ -114,7 +108,6 @@ mod tests { }, CursorShape::default(), None, - None, ); let _ = TerminalBuilder::new_with_pty( @@ -131,7 +124,6 @@ mod tests { }, CursorShape::default(), None, - None, ); }; } diff --git a/crates/gpui_term/src/terminal.rs b/crates/gpui_term/src/terminal.rs index 1a328ad..4dbedc7 100644 --- a/crates/gpui_term/src/terminal.rs +++ b/crates/gpui_term/src/terminal.rs @@ -202,6 +202,10 @@ pub trait TerminalBackend: Send { fn mouse_mode(&self, shift: bool) -> bool; fn selection_started(&self) -> bool; + fn has_exited(&self) -> bool { + false + } + /// Clears any active selection. /// /// This is used to implement common terminal behavior: any user input (typing/paste) cancels @@ -1436,6 +1440,10 @@ impl Terminal { self.inner.last_content() } + pub fn has_exited(&self) -> bool { + self.inner.has_exited() + } + pub fn matches(&self) -> &[RangeInclusive] { self.inner.matches() } diff --git a/locales/en.yml b/locales/en.yml index 846131e..0ab08c1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -158,6 +158,7 @@ SessionsSidebar: Status: Connecting: "connecting..." Empty: "No sessions yet." + LoadError: "Failed to load sessions from disk. Check logs for details." Context: Edit: "Edit" Delete: "Delete" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 73f3185..2350dc0 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -158,6 +158,7 @@ SessionsSidebar: Status: Connecting: "连接中..." Empty: "暂无会话。" + LoadError: "从磁盘加载会话失败,请查看日志详情。" Context: Edit: "编辑" Delete: "删除" diff --git a/termua/src/panel/sessions_sidebar/actions.rs b/termua/src/panel/sessions_sidebar/actions.rs index b49621b..754d73c 100644 --- a/termua/src/panel/sessions_sidebar/actions.rs +++ b/termua/src/panel/sessions_sidebar/actions.rs @@ -28,7 +28,13 @@ impl SessionsSidebarView { .placeholder(t!("SessionsSidebar.Placeholder.Search").to_string()) }); - let sessions = load_all_sessions().unwrap_or_default(); + let (sessions, has_load_error) = match load_all_sessions() { + Ok(sessions) => (sessions, false), + Err(err) => { + log::error!("SessionsSidebar: failed to load sessions: {err:#}"); + (Vec::new(), true) + } + }; let session_summaries = sessions .iter() .map(tree::SessionTreeSummary::from_session) @@ -66,6 +72,7 @@ impl SessionsSidebarView { focus_handle: cx.focus_handle(), search_input, query: String::new(), + has_load_error, reload_epoch: 0, reload_in_flight: false, reload_pending: false, @@ -174,6 +181,7 @@ impl SessionsSidebarView { this.reload_in_flight = false; match result { Ok(sessions) => { + this.has_load_error = false; this.session_summaries = sessions .iter() .map(tree::SessionTreeSummary::from_session) @@ -182,6 +190,7 @@ impl SessionsSidebarView { this.rebuild_tree(window, cx); } Err(err) => { + this.has_load_error = true; log::warn!("SessionsSidebar: failed to load sessions: {err:#}"); cx.notify(); window.refresh(); diff --git a/termua/src/panel/sessions_sidebar/render.rs b/termua/src/panel/sessions_sidebar/render.rs index 94cd9ba..b248003 100644 --- a/termua/src/panel/sessions_sidebar/render.rs +++ b/termua/src/panel/sessions_sidebar/render.rs @@ -442,6 +442,24 @@ impl Render for SessionsSidebarView { .min_h_0() .bg(cx.theme().background) .child(div().p_2().child(Input::new(&self.search_input))) + .when(self.has_load_error, |this| { + this.child( + div() + .id("termua-sessions-sidebar-load-error") + .debug_selector(|| "termua-sessions-sidebar-load-error".to_string()) + .mx_2() + .mb_2() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().warning.opacity(0.25)) + .bg(cx.theme().warning.opacity(0.08)) + .text_xs() + .text_color(cx.theme().warning) + .whitespace_normal() + .child(t!("SessionsSidebar.LoadError").to_string()), + ) + }) .child( ContextMenu::new( "termua-sessions-sidebar-context-menu", diff --git a/termua/src/panel/sessions_sidebar/state.rs b/termua/src/panel/sessions_sidebar/state.rs index 7cf1548..4435bd4 100644 --- a/termua/src/panel/sessions_sidebar/state.rs +++ b/termua/src/panel/sessions_sidebar/state.rs @@ -19,6 +19,7 @@ pub struct SessionsSidebarView { pub(super) focus_handle: FocusHandle, pub(super) search_input: Entity, pub(super) query: String, + pub(super) has_load_error: bool, pub(super) reload_epoch: usize, pub(super) reload_in_flight: bool, pub(super) reload_pending: bool, diff --git a/termua/src/panel/sessions_sidebar/tests.rs b/termua/src/panel/sessions_sidebar/tests.rs index 6ace4e2..fd81b9e 100644 --- a/termua/src/panel/sessions_sidebar/tests.rs +++ b/termua/src/panel/sessions_sidebar/tests.rs @@ -920,6 +920,60 @@ fn folder_right_click_shows_new_session_menu_item(cx: &mut gpui::TestAppContext) .expect("expected New Session item in folder context menu"); } +#[gpui::test] +fn sidebar_shows_load_error_when_disk_sessions_cannot_be_parsed(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + }); + + let tmp_dir = std::env::temp_dir().join(format!( + "termua-sessions-sidebar-load-error-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp_dir).unwrap(); + let db_path = tmp_dir.join("termua").join("termua.db"); + let _guard = crate::store::tests::override_termua_db_path(db_path.clone()); + + let session_id = crate::store::save_local_session( + "local", + "bash", + crate::settings::TerminalBackend::Wezterm, + "bash", + "xterm-256color", + "UTF-8", + ) + .unwrap(); + + let conn = rusqlite::Connection::open(db_path).unwrap(); + conn.execute( + "UPDATE sessions SET backend = 'alacritty2' WHERE id = ?1", + rusqlite::params![session_id], + ) + .unwrap(); + + let (root, cx) = cx.add_window_view(|window, cx| { + let sidebar = cx.new(|cx| SessionsSidebarView::new(window, cx)); + gpui_component::Root::new(sidebar, window, cx) + }); + + cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(600.)), + gpui::AvailableSpace::Definite(gpui::px(240.)), + ), + move |_, _| div().size_full().child(root), + ); + cx.run_until_parked(); + + cx.debug_bounds("termua-sessions-sidebar-load-error") + .expect("expected a visible load error when disk sessions cannot be parsed"); +} + #[gpui::test] fn session_labels_do_not_wrap_when_sidebar_is_narrow(cx: &mut gpui::TestAppContext) { cx.update(|app| { diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs index d384ac9..270ff68 100644 --- a/termua/src/panel/terminal_panel.rs +++ b/termua/src/panel/terminal_panel.rs @@ -136,6 +136,14 @@ impl TerminalPanel { } } + pub(crate) fn id(&self) -> usize { + self.id + } + + pub(crate) fn kind(&self) -> PanelKind { + self.kind + } + pub(crate) fn terminal_view(&self) -> gpui::Entity { self.terminal_view.clone() } diff --git a/termua/src/window/main_window/actions.rs b/termua/src/window/main_window/actions.rs index ce4e817..d5bb5f7 100644 --- a/termua/src/window/main_window/actions.rs +++ b/termua/src/window/main_window/actions.rs @@ -132,14 +132,15 @@ impl TermuaWindow { let sub = cx.subscribe_in( &terminal, window, - move |_this, _terminal, event, window, cx| { - Self::handle_terminal_event_for_messages(panel_id, &tab_label, event, window, cx); + move |this, _terminal, event, window, cx| { + this.handle_terminal_event_for_messages(panel_id, &tab_label, event, window, cx); }, ); self._subscriptions.push(sub); } fn handle_terminal_event_for_messages( + &mut self, panel_id: usize, tab_label: &SharedString, event: &TerminalEvent, @@ -149,7 +150,12 @@ impl TermuaWindow { if Self::handle_terminal_event_toast(event, window, cx) { return; } - let _ = Self::handle_terminal_event_sftp_upload(panel_id, tab_label, event, window, cx); + if Self::handle_terminal_event_sftp_upload(panel_id, tab_label, event, window, cx) { + return; + } + if matches!(event, TerminalEvent::CloseTerminal) { + self.close_terminal_panel_on_event(panel_id, window, cx); + } } fn handle_terminal_event_toast( @@ -229,6 +235,65 @@ impl TermuaWindow { (id, task) } + fn find_visible_terminal_panel( + &self, + cx: &App, + mut predicate: impl FnMut(&TerminalPanel, &App) -> bool, + ) -> Option> { + self.dock_area + .read(cx) + .visible_tab_panels(cx) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(cx).active_panel(cx)) + .find(|panel| { + panel + .view() + .downcast::() + .ok() + .is_some_and(|terminal_panel| predicate(&terminal_panel.read(cx), cx)) + }) + } + + fn close_terminal_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.dock_area.update(cx, |dock, cx| { + dock.remove_panel_from_all_docks(panel, window, cx); + }); + cx.notify(); + } + + fn close_terminal_panel_on_event( + &mut self, + panel_id: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(panel) = self.find_visible_terminal_panel(cx, |terminal_panel, cx| { + if terminal_panel.id() != panel_id { + return false; + } + + if terminal_panel.kind() != PanelKind::Ssh { + return true; + } + + !terminal_panel + .terminal_view() + .read(cx) + .terminal + .read(cx) + .has_exited() + }) else { + return; + }; + + self.close_terminal_panel(panel, window, cx); + } + fn handle_terminal_event_sftp_upload( panel_id: usize, tab_label: &SharedString, @@ -1913,7 +1978,6 @@ impl TermuaWindow { PtySource::Serial { opts: opts.clone() }, CursorShape::default(), None, - None, ); let builder = match builder { @@ -2723,7 +2787,7 @@ impl TermuaWindow { self._subscriptions.push(sub); } - fn subscribe_terminal_view_events( + pub(crate) fn subscribe_terminal_view_events( &mut self, terminal_view: &gpui::Entity, window: &mut Window, @@ -2734,12 +2798,18 @@ impl TermuaWindow { let subscription = cx.subscribe_in( &source_terminal_view, window, - move |this, _, event, _window, cx| match event { - TerminalEvent::UserInput(input) => this.on_terminal_user_input( - source_terminal_view_for_cb.clone(), - input.clone(), - cx, - ), + move |this, _, event, window, cx| match event { + TerminalEvent::UserInput(input) => { + if this.close_exited_ssh_panel(&source_terminal_view_for_cb, input, window, cx) + { + return; + } + this.on_terminal_user_input( + source_terminal_view_for_cb.clone(), + input.clone(), + cx, + ) + } TerminalEvent::Toast { level, title, @@ -2896,16 +2966,9 @@ impl TermuaWindow { }; let terminal = cx.new(|cx| { - TerminalBuilder::new( - backend_type, - env, - CursorShape::default(), - None, - id as u64, - None, - ) - .expect("local terminal builder should succeed") - .subscribe(cx) + TerminalBuilder::new(backend_type, env, CursorShape::default(), None, id as u64) + .expect("local terminal builder should succeed") + .subscribe(cx) }); self.build_wired_terminal_panel(id, kind, tab_label, None, terminal, window, cx) } @@ -2961,6 +3024,43 @@ impl TermuaWindow { cx.notify(); } + fn close_exited_ssh_panel( + &mut self, + source: &gpui::Entity, + input: &TerminalUserInput, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let TerminalUserInput::Keystroke(keystroke) = input else { + return false; + }; + if keystroke.key.as_str() != "d" + || !keystroke.modifiers.control + || keystroke.modifiers.alt + || keystroke.modifiers.platform + || keystroke.modifiers.function + || keystroke.modifiers.shift + { + return false; + } + + let Some(panel) = self.find_visible_terminal_panel(cx, |terminal_panel, cx| { + terminal_panel.kind() == PanelKind::Ssh + && terminal_panel.terminal_view().entity_id() == source.entity_id() + && terminal_panel + .terminal_view() + .read(cx) + .terminal + .read(cx) + .has_exited() + }) else { + return false; + }; + + self.close_terminal_panel(panel, window, cx); + true + } + fn on_terminal_user_input( &mut self, source: gpui::Entity, diff --git a/termua/src/window/main_window/state.rs b/termua/src/window/main_window/state.rs index b13f0d2..d80b53e 100644 --- a/termua/src/window/main_window/state.rs +++ b/termua/src/window/main_window/state.rs @@ -255,7 +255,6 @@ impl TermuaWindow { PtySource::Ssh { env, opts }, CursorShape::default(), None, - None, ) }, ); diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index c507773..ddc9b0c 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -17,7 +17,7 @@ use gpui::{ use gpui_component::input::InputState; use gpui_term::{ Authentication, CursorShape, Event as TerminalEvent, SshOptions, Terminal, TerminalBackend, - TerminalBounds, TerminalType, TerminalView, + TerminalBounds, TerminalType, TerminalView, UserInput as TerminalUserInput, }; use super::*; @@ -693,6 +693,396 @@ fn main_window_renders_lock_overlay_when_locked(cx: &mut gpui::TestAppContext) { assert!(window.debug_bounds("termua-lock-password-input").is_some()); } +#[gpui::test] +fn close_terminal_event_closes_local_terminal_tab(cx: &mut gpui::TestAppContext) { + use std::{cell::RefCell, rc::Rc}; + + use gpui_dock::{DockPlacement, PanelView}; + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + app.set_global(lock_screen::LockState::new_for_test(Duration::from_secs( + 60, + ))); + app.set_global(notification::NotifyState::default()); + }); + + let termua_slot: Rc>>> = Rc::new(RefCell::new(None)); + let slot_for_root = termua_slot.clone(); + + let (root, window_cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| TermuaWindow::new(window, cx)); + *slot_for_root.borrow_mut() = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + let termua = termua_slot + .borrow() + .as_ref() + .expect("expected TermuaWindow view to be captured") + .clone(); + + let terminal = window_cx.update(|window, app| { + let recording_active = Arc::new(AtomicBool::new(false)); + let terminal = app.new(|_cx| { + Terminal::new( + TerminalType::WezTerm, + Box::new(FakeBackend::new(recording_active.clone())), + ) + }); + let terminal_view = app.new(|cx| TerminalView::new(terminal.clone(), window, cx)); + let panel = app.new(|_| { + crate::panel::TerminalPanel::new( + 42, + crate::panel::PanelKind::Local, + "bash".into(), + None, + terminal_view, + ) + }); + + termua.update(app, |this, cx| { + this.subscribe_terminal_events_for_messages( + terminal.clone(), + 42, + "bash".into(), + window, + cx, + ); + this.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + }); + + terminal + }); + + let root_for_draw = root.clone(); + window_cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(900.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |_, _| div().size_full().child(root_for_draw), + ); + window_cx.run_until_parked(); + + let terminal_tabs_before = window_cx.update(|_window, app| { + termua + .read(app) + .dock_area + .read(app) + .visible_tab_panels(app) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(app).active_panel(app)) + .filter(|panel| { + panel + .view() + .downcast::() + .is_ok() + }) + .count() + }); + assert_eq!( + terminal_tabs_before, 1, + "expected one terminal tab before close event" + ); + + window_cx.update(|_window, app| { + terminal.update(app, |_terminal, cx| { + cx.emit(TerminalEvent::CloseTerminal); + }); + }); + window_cx.run_until_parked(); + + let terminal_tabs_after = window_cx.update(|_window, app| { + termua + .read(app) + .dock_area + .read(app) + .visible_tab_panels(app) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(app).active_panel(app)) + .filter(|panel| { + panel + .view() + .downcast::() + .is_ok() + }) + .count() + }); + assert_eq!( + terminal_tabs_after, 0, + "expected close event on local terminal to remove the terminal tab" + ); +} + +#[gpui::test] +fn exited_ssh_terminal_closes_on_second_ctrl_d(cx: &mut gpui::TestAppContext) { + use std::{cell::RefCell, rc::Rc}; + + use gpui::Keystroke; + use gpui_dock::{DockPlacement, PanelView}; + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + app.set_global(lock_screen::LockState::new_for_test(Duration::from_secs( + 60, + ))); + app.set_global(notification::NotifyState::default()); + }); + + let termua_slot: Rc>>> = Rc::new(RefCell::new(None)); + let slot_for_root = termua_slot.clone(); + + let (root, window_cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| TermuaWindow::new(window, cx)); + *slot_for_root.borrow_mut() = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + let termua = termua_slot + .borrow() + .as_ref() + .expect("expected TermuaWindow view to be captured") + .clone(); + + let terminal_view = window_cx.update(|window, app| { + let recording_active = Arc::new(AtomicBool::new(false)); + let terminal = app.new(|_cx| { + Terminal::new( + TerminalType::WezTerm, + Box::new(FakeBackend::with_exited(recording_active.clone(), true)), + ) + }); + let terminal_view = app.new(|cx| TerminalView::new(terminal.clone(), window, cx)); + let panel = app.new(|_| { + crate::panel::TerminalPanel::new( + 77, + crate::panel::PanelKind::Ssh, + "ssh demo".into(), + None, + terminal_view.clone(), + ) + }); + + termua.update(app, |this, cx| { + this.subscribe_terminal_events_for_messages( + terminal.clone(), + 77, + "ssh demo".into(), + window, + cx, + ); + this.subscribe_terminal_view_events(&terminal_view, window, cx); + this.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + }); + + terminal_view + }); + + let root_for_draw = root.clone(); + window_cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(900.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |_, _| div().size_full().child(root_for_draw), + ); + window_cx.run_until_parked(); + + window_cx.update(|_window, app| { + let terminal = terminal_view.read(app).terminal.clone(); + terminal.update(app, |_terminal, cx| { + cx.emit(TerminalEvent::CloseTerminal); + }); + }); + window_cx.run_until_parked(); + + let terminal_tabs_after_exit = window_cx.update(|_window, app| { + termua + .read(app) + .dock_area + .read(app) + .visible_tab_panels(app) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(app).active_panel(app)) + .filter(|panel| { + panel + .view() + .downcast::() + .is_ok() + }) + .count() + }); + assert_eq!( + terminal_tabs_after_exit, 1, + "expected close event on exited ssh terminal to stay open" + ); + + window_cx.update(|_window, app| { + terminal_view.update(app, |_view, cx| { + cx.emit(TerminalEvent::UserInput(TerminalUserInput::Keystroke( + Keystroke::parse("ctrl-d").unwrap(), + ))); + }); + }); + window_cx.run_until_parked(); + + let terminal_tabs_after = window_cx.update(|_window, app| { + termua + .read(app) + .dock_area + .read(app) + .visible_tab_panels(app) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(app).active_panel(app)) + .filter(|panel| { + panel + .view() + .downcast::() + .is_ok() + }) + .count() + }); + assert_eq!( + terminal_tabs_after, 0, + "expected exited ssh terminal to close on Ctrl-D" + ); +} + +#[gpui::test] +fn active_ssh_terminal_does_not_close_on_first_ctrl_d(cx: &mut gpui::TestAppContext) { + use std::{cell::RefCell, rc::Rc}; + + use gpui::Keystroke; + use gpui_dock::{DockPlacement, PanelView}; + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + app.set_global(lock_screen::LockState::new_for_test(Duration::from_secs( + 60, + ))); + app.set_global(notification::NotifyState::default()); + }); + + let termua_slot: Rc>>> = Rc::new(RefCell::new(None)); + let slot_for_root = termua_slot.clone(); + + let (root, window_cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| TermuaWindow::new(window, cx)); + *slot_for_root.borrow_mut() = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + let termua = termua_slot + .borrow() + .as_ref() + .expect("expected TermuaWindow view to be captured") + .clone(); + + let terminal_view = window_cx.update(|window, app| { + let recording_active = Arc::new(AtomicBool::new(false)); + let terminal = app.new(|_cx| { + Terminal::new( + TerminalType::WezTerm, + Box::new(FakeBackend::with_exited(recording_active.clone(), false)), + ) + }); + let terminal_view = app.new(|cx| TerminalView::new(terminal.clone(), window, cx)); + let panel = app.new(|_| { + crate::panel::TerminalPanel::new( + 78, + crate::panel::PanelKind::Ssh, + "ssh demo".into(), + None, + terminal_view.clone(), + ) + }); + + termua.update(app, |this, cx| { + this.subscribe_terminal_view_events(&terminal_view, window, cx); + this.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + }); + + terminal_view + }); + + let root_for_draw = root.clone(); + window_cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(900.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |_, _| div().size_full().child(root_for_draw), + ); + window_cx.run_until_parked(); + + window_cx.update(|_window, app| { + terminal_view.update(app, |_view, cx| { + cx.emit(TerminalEvent::UserInput(TerminalUserInput::Keystroke( + Keystroke::parse("ctrl-d").unwrap(), + ))); + }); + }); + window_cx.run_until_parked(); + + let terminal_tabs_after = window_cx.update(|_window, app| { + termua + .read(app) + .dock_area + .read(app) + .visible_tab_panels(app) + .into_iter() + .filter_map(|tab_panel| tab_panel.read(app).active_panel(app)) + .filter(|panel| { + panel + .view() + .downcast::() + .is_ok() + }) + .count() + }); + assert_eq!( + terminal_tabs_after, 1, + "expected active ssh terminal to stay open on first Ctrl-D" + ); +} + #[gpui::test] fn sftp_events_are_recorded_in_message_center(cx: &mut gpui::TestAppContext) { use gpui_term::Terminal; @@ -2005,13 +2395,19 @@ fn ssh_sessions_with_missing_password_show_a_notification(cx: &mut gpui::TestApp struct FakeBackend { content: gpui_term::TerminalContent, recording_active: Arc, + exited: bool, } impl FakeBackend { fn new(recording_active: Arc) -> Self { + Self::with_exited(recording_active, false) + } + + fn with_exited(recording_active: Arc, exited: bool) -> Self { Self { content: gpui_term::TerminalContent::default(), recording_active, + exited, } } } @@ -2035,6 +2431,10 @@ impl TerminalBackend for FakeBackend { None } + fn has_exited(&self) -> bool { + self.exited + } + fn vi_mode_enabled(&self) -> bool { false } From 1c5dfcf8830548754c74fd320c3c2403a6084cc0 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 14:53:50 +0800 Subject: [PATCH 02/15] ci: add codecov action --- .github/workflows/codecov.yml | 27 +++++++++++++++++++++++ README.md | 35 +++++++++++------------------- README.zh-CN.md | 40 ++++++++++++----------------------- 3 files changed, 53 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/codecov.yml diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..b86f9d3 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,27 @@ +name: coverage + +on: [ push ] +jobs: + test: + name: coverage + runs-on: ubuntu-latest + container: + image: xd009642/tarpaulin:develop-nightly + options: --security-opt seccomp=unconfined + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + target: ${{ matrix.target }} + run: | + cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml + - name: Upload to codecov.io + uses: codecov/codecov-action@v5 + with: + token: ${{secrets.CODECOV_TOKEN}} + fail_ci_if_error: true + slug: iamazy/termua diff --git a/README.md b/README.md index 7e74ce2..0bdaa3f 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,22 @@ -

- English | 简体中文 -

+
+

Termua

+

+ an open-source cross-platform terminal application built with GPUI and powered by the Alacritty / WezTerm terminal backends. +

-

- Termua Logo -

+[![codecov](https://codecov.io/github/iamazy/termua/graph/badge.svg?token=QRH8H0O6P5)](https://codecov.io/github/iamazy/termua) +![License](https://img.shields.io/badge/license-AGPL--3.0-blue) -

- an open-source cross-platform terminal application built with GPUI and powered by the Alacritty / WezTerm terminal backends. -

- -

-

-

+
termua
+

+ English | 简体中文 +

+ ### Features ❇️ - [x] Cross-platform: Linux / macOS / Windows diff --git a/README.zh-CN.md b/README.zh-CN.md index affc39c..bcdc86d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,37 +1,25 @@ -

- English | 简体中文 -

- -

- Termua Logo -

- -

- Termua: 一款使用 GPUI 构建,基于 Alacritty / Wezterm 内核的开源跨平台终端应用。 -

- -

+

+

Termua

+

+ Termua: 一款使用 GPUI 构建,基于 Alacritty / Wezterm 内核的开源跨平台终端应用 +

+

集成 SSH / Serial / SFTP / 回放 / 终端共享 / AI 助手,目标是成为一个现代化的终端工作台。

-

-

-

+[![codecov](https://codecov.io/github/iamazy/termua/graph/badge.svg?token=QRH8H0O6P5)](https://codecov.io/github/iamazy/termua) +![License](https://img.shields.io/badge/license-AGPL--3.0-blue) + +
termua
+

+ English | 简体中文 +

+ ### 特性 ❇️ - [x] 跨平台:Linux / macOS / Windows From 127fffe9faae97e8c55a7648e4bc11c0d9699f61 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 14:57:51 +0800 Subject: [PATCH 03/15] chore: clean code --- crates/gpui_term/examples/term.rs | 33 ++++--------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/crates/gpui_term/examples/term.rs b/crates/gpui_term/examples/term.rs index 13e104b..0626c59 100644 --- a/crates/gpui_term/examples/term.rs +++ b/crates/gpui_term/examples/term.rs @@ -25,38 +25,13 @@ fn main() { if std::env::var_os("WAYLAND_DISPLAY").is_some() { window.set_background_appearance(WindowBackgroundAppearance::Transparent); } - // let terminal = cx.new(|cx| { - // TerminalBuilder::new( - // TerminalType::Alacritty, - // std::collections::HashMap::default(), - // CursorShape::default(), - // None, - // 0, - // None, - // ) - // .unwrap() - // .subscribe(cx) - // }); let terminal = cx.new(|cx| { - TerminalBuilder::new_with_pty( - TerminalType::WezTerm, - PtySource::Ssh { - env: HashMap::default(), - opts: SshOptions { - host: "127.0.0.1".to_string(), - port: Some(22), - auth: Authentication::Password( - "iamazy".to_string(), - "1448588084".to_string(), - ), - proxy: gpui_term::SshProxyMode::Inherit, - backend: gpui_term::SshBackend::default(), - tcp_nodelay: false, - tcp_keepalive: false, - }, - }, + TerminalBuilder::new( + TerminalType::Alacritty, + HashMap::default(), CursorShape::default(), None, + 0, ) .unwrap() .subscribe(cx) From a91c14ee5d5c3fc3119e5f57a0d9542b69f4d95f Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 14:58:37 +0800 Subject: [PATCH 04/15] ci: add codecov action --- .github/{workflows => }/codecov.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{workflows => }/codecov.yml (100%) diff --git a/.github/workflows/codecov.yml b/.github/codecov.yml similarity index 100% rename from .github/workflows/codecov.yml rename to .github/codecov.yml From a1f94f4a6b77a931ce1d73916448f19398b263d5 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:01:33 +0800 Subject: [PATCH 05/15] chore: add codecov action --- .github/{ => workflows}/codecov.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename .github/{ => workflows}/codecov.yml (82%) diff --git a/.github/codecov.yml b/.github/workflows/codecov.yml similarity index 82% rename from .github/codecov.yml rename to .github/workflows/codecov.yml index b86f9d3..ca4759e 100644 --- a/.github/codecov.yml +++ b/.github/workflows/codecov.yml @@ -17,8 +17,9 @@ jobs: toolchain: stable override: true target: ${{ matrix.target }} + - name: Generate code coverage run: | - cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml + cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: From baa7203aad6013efeea6956d9c58269b4a5be500 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:04:07 +0800 Subject: [PATCH 06/15] chore: update README --- README.md | 4 ++-- README.zh-CN.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0bdaa3f..541df91 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Termua

-

- an open-source cross-platform terminal application built with GPUI and powered by the Alacritty / WezTerm terminal backends. +
+ an open-source cross-platform terminal application built with GPUI
and powered by the Alacritty / WezTerm terminal backends.

[![codecov](https://codecov.io/github/iamazy/termua/graph/badge.svg?token=QRH8H0O6P5)](https://codecov.io/github/iamazy/termua) diff --git a/README.zh-CN.md b/README.zh-CN.md index bcdc86d..72bd9fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,7 @@

Termua

- Termua: 一款使用 GPUI 构建,基于 Alacritty / Wezterm 内核的开源跨平台终端应用 + 一款使用 GPUI 构建,基于 Alacritty / Wezterm 内核的开源跨平台终端应用

集成 SSH / Serial / SFTP / 回放 / 终端共享 / AI 助手,目标是成为一个现代化的终端工作台。 From 1027e6ee4e908ce62eefc6e873beaa8698fa44ed Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:05:28 +0800 Subject: [PATCH 07/15] chore: update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 541df91..ae6ee7d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Termua

-
- an open-source cross-platform terminal application built with GPUI
and powered by the Alacritty / WezTerm terminal backends. +

+ an open-source cross-platform terminal application built with GPUI
and powered by the Alacritty / WezTerm terminal backends.

[![codecov](https://codecov.io/github/iamazy/termua/graph/badge.svg?token=QRH8H0O6P5)](https://codecov.io/github/iamazy/termua) From 9fc8e8866bd11763a9dec3918df83a012692c705 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:13:18 +0800 Subject: [PATCH 08/15] chore: make clippy happy --- crates/gpui_term/src/backends/alacritty/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index 72f1cbb..45b5687 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -1589,8 +1589,7 @@ impl TerminalBackend for AlacrittyBackend { let last_col = grid.last_column(); let mut out = Vec::new(); - let mut line = top + start_line; - for _ in 0..count { + for (line, _) in (top + start_line..).zip((0..count)) { if line > bottom { break; } @@ -1599,7 +1598,6 @@ impl TerminalBackend for AlacrittyBackend { AlacPoint::new(line, last_col), ); out.push(s.trim_end_matches(|c: char| c.is_whitespace()).to_string()); - line += 1usize; } out } @@ -1626,8 +1624,7 @@ impl TerminalBackend for AlacrittyBackend { let mut out: Vec = Vec::with_capacity(cols * count); let mut rows = 0usize; - let mut line = top + start_line; - for r in 0..count { + for (line, r) in (top + start_line..).zip((0..count)) { if line > bottom { break; } @@ -1639,7 +1636,6 @@ impl TerminalBackend for AlacrittyBackend { cell: map_cell(cell), }); } - line += 1usize; } (cols, rows, out) From d727b04b418d2bf2fd1506258a3bb5ba3fdb38e8 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:16:05 +0800 Subject: [PATCH 09/15] ci: add codecov action --- .github/workflows/codecov.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ca4759e..563806f 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -17,6 +17,11 @@ jobs: toolchain: stable override: true target: ${{ matrix.target }} + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y curl dpkg rpm + sudo apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev zlib1g-dev - name: Generate code coverage run: | cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml From 72c23d70ede98b99caa875abb0ac0a722083ad4a Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:24:10 +0800 Subject: [PATCH 10/15] ci: add codecov action --- .github/workflows/codecov.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 563806f..2b18aad 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -16,12 +16,11 @@ jobs: profile: minimal toolchain: stable override: true - target: ${{ matrix.target }} - name: Install Linux dependencies run: | - sudo apt-get update - sudo apt-get install -y curl dpkg rpm - sudo apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev zlib1g-dev + apt-get update + apt-get install -y curl dpkg rpm + apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev zlib1g-dev - name: Generate code coverage run: | cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml From 789969f2d77b94526e5ea11697696d4611cd1acc Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:31:24 +0800 Subject: [PATCH 11/15] Revert "chore: make clippy happy" This reverts commit 9fc8e8866bd11763a9dec3918df83a012692c705. --- crates/gpui_term/src/backends/alacritty/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index 45b5687..72f1cbb 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -1589,7 +1589,8 @@ impl TerminalBackend for AlacrittyBackend { let last_col = grid.last_column(); let mut out = Vec::new(); - for (line, _) in (top + start_line..).zip((0..count)) { + let mut line = top + start_line; + for _ in 0..count { if line > bottom { break; } @@ -1598,6 +1599,7 @@ impl TerminalBackend for AlacrittyBackend { AlacPoint::new(line, last_col), ); out.push(s.trim_end_matches(|c: char| c.is_whitespace()).to_string()); + line += 1usize; } out } @@ -1624,7 +1626,8 @@ impl TerminalBackend for AlacrittyBackend { let mut out: Vec = Vec::with_capacity(cols * count); let mut rows = 0usize; - for (line, r) in (top + start_line..).zip((0..count)) { + let mut line = top + start_line; + for r in 0..count { if line > bottom { break; } @@ -1636,6 +1639,7 @@ impl TerminalBackend for AlacrittyBackend { cell: map_cell(cell), }); } + line += 1usize; } (cols, rows, out) From 25fac11c485630e510d198bcdd81a1b4b938d2a3 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 15:36:26 +0800 Subject: [PATCH 12/15] chore: make clippy happy --- crates/gpui_term/examples/term.rs | 4 +--- termua/src/window/main_window/tests.rs | 9 +++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/gpui_term/examples/term.rs b/crates/gpui_term/examples/term.rs index 0626c59..fdb7a02 100644 --- a/crates/gpui_term/examples/term.rs +++ b/crates/gpui_term/examples/term.rs @@ -6,9 +6,7 @@ use gpui::{ }; use gpui_component::Root; use gpui_component_assets::Assets; -use gpui_term::{ - Authentication, CursorShape, PtySource, SshOptions, TerminalBuilder, TerminalType, TerminalView, -}; +use gpui_term::{CursorShape, TerminalBuilder, TerminalType, TerminalView}; fn main() { env_logger::init(); diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index ddc9b0c..3cd0884 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -766,14 +766,13 @@ fn close_terminal_event_closes_local_terminal_tab(cx: &mut gpui::TestAppContext) terminal }); - let root_for_draw = root.clone(); window_cx.draw( gpui::point(gpui::px(0.), gpui::px(0.)), gpui::size( gpui::AvailableSpace::Definite(gpui::px(900.)), gpui::AvailableSpace::Definite(gpui::px(600.)), ), - move |_, _| div().size_full().child(root_for_draw), + move |_, _| div().size_full().child(root), ); window_cx.run_until_parked(); @@ -902,14 +901,13 @@ fn exited_ssh_terminal_closes_on_second_ctrl_d(cx: &mut gpui::TestAppContext) { terminal_view }); - let root_for_draw = root.clone(); window_cx.draw( gpui::point(gpui::px(0.), gpui::px(0.)), gpui::size( gpui::AvailableSpace::Definite(gpui::px(900.)), gpui::AvailableSpace::Definite(gpui::px(600.)), ), - move |_, _| div().size_full().child(root_for_draw), + move |_, _| div().size_full().child(root), ); window_cx.run_until_parked(); @@ -1041,14 +1039,13 @@ fn active_ssh_terminal_does_not_close_on_first_ctrl_d(cx: &mut gpui::TestAppCont terminal_view }); - let root_for_draw = root.clone(); window_cx.draw( gpui::point(gpui::px(0.), gpui::px(0.)), gpui::size( gpui::AvailableSpace::Definite(gpui::px(900.)), gpui::AvailableSpace::Definite(gpui::px(600.)), ), - move |_, _| div().size_full().child(root_for_draw), + move |_, _| div().size_full().child(root), ); window_cx.run_until_parked(); From 776ce2bbb74d531b43293d611abede347e0aa05f Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 16:18:50 +0800 Subject: [PATCH 13/15] chore: make clippy happy --- crates/gpui_term/src/backends/alacritty/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index 72f1cbb..fd8af86 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -1589,8 +1589,8 @@ impl TerminalBackend for AlacrittyBackend { let last_col = grid.last_column(); let mut out = Vec::new(); - let mut line = top + start_line; - for _ in 0..count { + for offset in 0..count { + let line = top + start_line + offset; if line > bottom { break; } @@ -1599,7 +1599,6 @@ impl TerminalBackend for AlacrittyBackend { AlacPoint::new(line, last_col), ); out.push(s.trim_end_matches(|c: char| c.is_whitespace()).to_string()); - line += 1usize; } out } @@ -1626,8 +1625,8 @@ impl TerminalBackend for AlacrittyBackend { let mut out: Vec = Vec::with_capacity(cols * count); let mut rows = 0usize; - let mut line = top + start_line; for r in 0..count { + let line = top + start_line + r; if line > bottom { break; } @@ -1639,7 +1638,6 @@ impl TerminalBackend for AlacrittyBackend { cell: map_cell(cell), }); } - line += 1usize; } (cols, rows, out) From e1bcc70c54994f3fdb363686d48c695401fb5626 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 16:26:29 +0800 Subject: [PATCH 14/15] ci: add coverage action --- .github/workflows/codecov.yml | 32 ------------ .github/workflows/coverage.yml | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/codecov.yml create mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index 2b18aad..0000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: coverage - -on: [ push ] -jobs: - test: - name: coverage - runs-on: ubuntu-latest - container: - image: xd009642/tarpaulin:develop-nightly - options: --security-opt seccomp=unconfined - steps: - - uses: actions/checkout@v4 - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - name: Install Linux dependencies - run: | - apt-get update - apt-get install -y curl dpkg rpm - apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev zlib1g-dev - - name: Generate code coverage - run: | - cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml - - name: Upload to codecov.io - uses: codecov/codecov-action@v5 - with: - token: ${{secrets.CODECOV_TOKEN}} - fail_ci_if_error: true - slug: iamazy/termua diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..392c7db --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,93 @@ +name: coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + coverage: + name: coverage + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: llvm-tools-preview + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: codecov-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + curl \ + dpkg \ + rpm \ + libxcb1-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libssl-dev \ + zlib1g-dev + + - uses: taiki-e/install-action@cargo-llvm-cov + + - name: Run tests with coverage instrumentation + run: cargo llvm-cov --workspace --all-features --no-report + + - name: Generate LCOV report + run: cargo llvm-cov report --lcov --output-path lcov.info + + - name: Generate HTML report + run: cargo llvm-cov report --html --output-dir coverage-html + + - name: Generate text summary + run: cargo llvm-cov report > coverage-summary.txt + + - name: Publish coverage summary + run: | + echo '## Coverage Summary' >> "$GITHUB_STEP_SUMMARY" + echo '```text' >> "$GITHUB_STEP_SUMMARY" + cat coverage-summary.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Upload LCOV artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: lcov.info + if-no-files-found: error + + - name: Upload HTML artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: coverage-html + if-no-files-found: error + + - name: Upload to codecov.io + if: ${{ secrets.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: lcov.info + fail_ci_if_error: true + slug: iamazy/termua From d8a57498f90b4343fedf9d98db7bd2905cd4b167 Mon Sep 17 00:00:00 2001 From: iamazy Date: Fri, 17 Apr 2026 16:27:55 +0800 Subject: [PATCH 15/15] Update coverage.yml --- .github/workflows/coverage.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 392c7db..bfb50d6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -84,7 +84,6 @@ jobs: if-no-files-found: error - name: Upload to codecov.io - if: ${{ secrets.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }}