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.
+
-
-
-
+[](https://codecov.io/github/iamazy/termua)
+
-
- an open-source cross-platform terminal application built with GPUI and powered by the Alacritty / WezTerm terminal backends.
-
-
-
-
-
+
+
+ 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: 一款使用 GPUI 构建,基于 Alacritty / Wezterm 内核的开源跨平台终端应用。
-
-
-
+
+
Termua
+
+
集成 SSH / Serial / SFTP / 回放 / 终端共享 / AI 助手,目标是成为一个现代化的终端工作台。
-
-
-
+[](https://codecov.io/github/iamazy/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
}