diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..bfb50d6 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,92 @@ +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 + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: lcov.info + fail_ci_if_error: true + slug: iamazy/termua diff --git a/README.md b/README.md index 7e74ce2..ae6ee7d 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. -

- -

-

- - Linux - - - Windows - - - macOS - -
-

+
termua
+

+ English | 简体中文 +

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

- English | 简体中文 -

- -

- Termua Logo -

- -

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

- -

+

+

Termua

+

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

+

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

-

-

- - Linux - - - Windows - - - macOS - -
-

+[![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 diff --git a/crates/gpui_term/examples/term.rs b/crates/gpui_term/examples/term.rs index 7202f1a..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(); @@ -25,39 +23,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, - None, + 0, ) .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..fd8af86 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 } @@ -1593,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; } @@ -1603,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 } @@ -1630,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; } @@ -1643,7 +1638,6 @@ impl TerminalBackend for AlacrittyBackend { cell: map_cell(cell), }); } - line += 1usize; } (cols, rows, out) @@ -1935,11 +1929,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 +1992,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 +2099,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..3cd0884 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,393 @@ 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 + }); + + 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), + ); + 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 + }); + + 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), + ); + 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 + }); + + 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), + ); + 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 +2392,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 +2428,10 @@ impl TerminalBackend for FakeBackend { None } + fn has_exited(&self) -> bool { + self.exited + } + fn vi_mode_enabled(&self) -> bool { false }