diff --git a/AGENTS.md b/AGENTS.md index bda195a..14c6d3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ When handing work off to another person or to the user, include: - Favor explicitness over cleverness. - Reuse existing patterns already present in the workspace. - Prefer modular, composable changes over tightly coupled one-off implementations. +- Avoid glob imports such as `use foo::*` or `use super::*`; import the specific items needed. - Add tests near the affected code when the repository already has a local testing pattern for that area. - Put unit tests at the bottom of the file when adding or updating inline tests. - Preserve consistency with existing naming, error handling, and module organization. diff --git a/Cargo.lock b/Cargo.lock index 21344fe..3fd9dec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7285,6 +7285,7 @@ dependencies = [ "smol", "termua_relay", "termua_zeroclaw", + "thiserror 2.0.17", "wezterm-ssh", "windows 0.62.2", ] diff --git a/assets/icons/fish.svg b/assets/icons/fish.svg deleted file mode 100644 index da15e41..0000000 --- a/assets/icons/fish.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/nushell.svg b/assets/icons/nushell.svg deleted file mode 100644 index 2bb157a..0000000 --- a/assets/icons/nushell.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sh.svg b/assets/icons/sh.svg deleted file mode 100644 index 3c7fc18..0000000 --- a/assets/icons/sh.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/shell/termua-env.nu b/assets/shell/termua-env.nu deleted file mode 100644 index 6d6af77..0000000 --- a/assets/shell/termua-env.nu +++ /dev/null @@ -1,8 +0,0 @@ -# Termua OSC 133 environment integration (nushell). -# -# This file is written as the temporary `env.nu`. - -const __termua_orig_env = __TERMUA_ORIG_ENV__ -source-env $__termua_orig_env - -$env.TERMUA_OSC133_COMMAND_ACTIVE = false diff --git a/assets/shell/termua-osc133.fish b/assets/shell/termua-osc133.fish deleted file mode 100644 index e50af3d..0000000 --- a/assets/shell/termua-osc133.fish +++ /dev/null @@ -1,47 +0,0 @@ -# Termua OSC 133 shell integration (fish). -# -# Emits a minimal subset of OSC 133 markers: -# - A: prompt start -# - B: prompt end -# - C: command start -# - D;: command end - -status is-interactive; or return 0 - -set -g fish_handle_reflow 0 - -if set -q TERMUA_OSC133_FISH_INSTALLED - return 0 -end - -set -g TERMUA_OSC133_FISH_INSTALLED 1 - -function __termua_osc133_print --argument-names payload - printf '\e]133;%s\a' "$payload" -end - -if functions -q fish_prompt - functions -c fish_prompt __termua_osc133_orig_fish_prompt -else - function __termua_osc133_orig_fish_prompt - printf '> ' - end -end - -function fish_prompt - __termua_osc133_print "A" - __termua_osc133_orig_fish_prompt -end - -function __termua_osc133_preexec --on-event fish_preexec - set -g TERMUA_OSC133_COMMAND_ACTIVE 1 - __termua_osc133_print "B" - __termua_osc133_print "C" -end - -function __termua_osc133_postexec --on-event fish_postexec - if set -q TERMUA_OSC133_COMMAND_ACTIVE - __termua_osc133_print "D;$status" - set -e TERMUA_OSC133_COMMAND_ACTIVE - end -end diff --git a/assets/shell/termua-osc133.nu b/assets/shell/termua-osc133.nu deleted file mode 100644 index dc5438b..0000000 --- a/assets/shell/termua-osc133.nu +++ /dev/null @@ -1,30 +0,0 @@ -# Termua OSC 133 shell integration (nushell). -# -# This file is written as the temporary `config.nu`. It first sources the user's -# real Nushell config, then appends Termua's OSC 133 hooks. - -const __termua_orig_config = __TERMUA_ORIG_CONFIG__ -source $__termua_orig_config - -let __termua_pre_prompt = ($env.config | get -o hooks.pre_prompt | default []) -let __termua_pre_execution = ($env.config | get -o hooks.pre_execution | default []) -$env.config = ( - $env.config - | upsert hooks.pre_prompt ( - $__termua_pre_prompt | append {|| - if ($env.TERMUA_OSC133_COMMAND_ACTIVE? | default false) { - let __termua_exit = ($env.LAST_EXIT_CODE? | default 0) - print -n $"\u{1b}]133;D;($__termua_exit)\u{7}" - load-env { TERMUA_OSC133_COMMAND_ACTIVE: false } - } - print -n "\u{1b}]133;A\u{7}" - } - ) - | upsert hooks.pre_execution ( - $__termua_pre_execution | append {|| - print -n "\u{1b}]133;B\u{7}" - print -n "\u{1b}]133;C\u{7}" - load-env { TERMUA_OSC133_COMMAND_ACTIVE: true } - } - ) -) diff --git a/crates/gpui_common/src/assets.rs b/crates/gpui_common/src/assets.rs index c91423a..f198f1c 100644 --- a/crates/gpui_common/src/assets.rs +++ b/crates/gpui_common/src/assets.rs @@ -125,7 +125,6 @@ mod tests { TermuaIcon::FolderOpenBlue, TermuaIcon::FolderClosedBlue, TermuaIcon::GitBash, - TermuaIcon::Nushell, TermuaIcon::Pwsh, ] { assert!( diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index e83cdfe..cacd076 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -62,7 +62,7 @@ mod serial; pub mod ssh; fn local_pty_options_for_program_exists( - env: std::collections::HashMap, + env: HashMap, program_exists: impl FnOnce(&str) -> bool, ) -> Options { let shell_program = crate::shell::pick_shell_program_from_env(&env); @@ -1861,33 +1861,33 @@ mod shell_tests { #[test] fn local_pty_options_uses_termua_shell_when_available() { let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "fish".to_string()); + env.insert("TERMUA_SHELL".to_string(), "bash".to_string()); - let opts = local_pty_options_for_program_exists(env, |p| p == "fish"); + let opts = local_pty_options_for_program_exists(env, |p| p == "bash"); assert!(opts.shell.is_some()); } #[test] fn local_pty_options_uses_shell_integration_args() { let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "fish".to_string()); + env.insert("TERMUA_SHELL".to_string(), "pwsh".to_string()); env.insert( - "TERMUA_FISH_INIT".to_string(), - "/tmp/termua-test.fish".to_string(), + "TERMUA_PWSH_INIT".to_string(), + "/tmp/termua-test.ps1".to_string(), ); - let opts = local_pty_options_for_program_exists(env, |p| p == "fish"); + let opts = local_pty_options_for_program_exists(env, |p| p == "pwsh"); let shell = opts.shell.expect("expected shell"); let shell_debug = format!("{shell:?}"); - assert!(shell_debug.contains("fish")); - assert!(shell_debug.contains("--init-command")); - assert!(shell_debug.contains("source \\\"$TERMUA_FISH_INIT\\\"")); - assert!(shell_debug.contains("--interactive")); + assert!(shell_debug.contains("pwsh")); + assert!(shell_debug.contains("-NoLogo")); + assert!(shell_debug.contains("-NoExit")); + assert!(shell_debug.contains(". \\\"$env:TERMUA_PWSH_INIT\\\"")); } #[test] fn local_pty_options_falls_back_when_shell_not_found() { - let mut env = std::collections::HashMap::new(); + let mut env = HashMap::new(); env.insert("TERMUA_SHELL".to_string(), "fish".to_string()); let opts = local_pty_options_for_program_exists(env, |_p| false); diff --git a/crates/gpui_term/src/backends/wezterm/mod.rs b/crates/gpui_term/src/backends/wezterm/mod.rs index 512e375..9746930 100644 --- a/crates/gpui_term/src/backends/wezterm/mod.rs +++ b/crates/gpui_term/src/backends/wezterm/mod.rs @@ -3284,66 +3284,6 @@ mod tests { ); } - #[test] - fn local_shell_candidates_uses_fish_init_when_configured() { - let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "fish".to_string()); - env.insert( - "TERMUA_FISH_INIT".to_string(), - "/tmp/termua-test.fish".to_string(), - ); - - let candidates = super::shell_command_candidates_for_local_env(&env); - let argv: Vec = candidates[0] - .get_argv() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect(); - - assert_eq!( - argv, - vec![ - "fish", - "--init-command", - "source \"$TERMUA_FISH_INIT\"", - "--interactive" - ] - ); - } - - #[test] - fn local_shell_candidates_uses_nu_configs_when_configured() { - let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "nu".to_string()); - env.insert( - "TERMUA_NU_CONFIG".to_string(), - "/tmp/termua-config.nu".to_string(), - ); - env.insert( - "TERMUA_NU_ENV_CONFIG".to_string(), - "/tmp/termua-env.nu".to_string(), - ); - - let candidates = super::shell_command_candidates_for_local_env(&env); - let argv: Vec = candidates[0] - .get_argv() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect(); - - assert_eq!( - argv, - vec![ - "nu", - "--config", - "/tmp/termua-config.nu", - "--env-config", - "/tmp/termua-env.nu", - "--interactive" - ] - ); - } - #[test] fn local_shell_candidates_uses_powershell_init_when_configured() { let mut env = std::collections::HashMap::new(); diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs index 60e434a..4afcdf5 100644 --- a/crates/gpui_term/src/element.rs +++ b/crates/gpui_term/src/element.rs @@ -33,7 +33,7 @@ use crate::{ reserve_left_padding_without_line_numbers, should_relayout_for_mode_change, should_show_line_numbers, }, - scrollbar::{ + scrolling::{ SCROLLBAR_WIDTH, ScrollbarLayoutState, overlay_scrollbar_layout_state, paint_overlay_scrollbar, scroll_offset_for_drag_delta, scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y, @@ -3665,7 +3665,10 @@ fn terminal_view_bounds_for_suggestions_overlay( #[cfg(test)] mod suggestions_overlay_desc_tests { - use super::*; + use gpui::{Bounds, point, px, size}; + + use super::{SuggestionsOverlayHitTest, compute_suggestions_overlay_layout}; + use crate::terminal::TerminalBounds; #[test] fn layout_does_not_reserve_desc_row_when_empty() { @@ -3882,7 +3885,16 @@ mod suggestions_overlay_desc_tests { #[cfg(test)] mod tests { - use super::*; + use std::ops::RangeInclusive; + + use gpui::{Bounds, point, px, size}; + use gpui_component::Theme; + + use super::{ + compute_terminal_layout_metrics, highlight_quads_for_range, placeholder_highlight_bgs, + snippet_placeholder_bg_quads, + }; + use crate::{GridPoint, TerminalMode, view::line_number::should_relayout_for_mode_change}; #[test] fn placeholder_highlight_colors_are_theme_derived() { diff --git a/crates/gpui_term/src/shell.rs b/crates/gpui_term/src/shell.rs index 07b0e38..a0e63a1 100644 --- a/crates/gpui_term/src/shell.rs +++ b/crates/gpui_term/src/shell.rs @@ -3,18 +3,15 @@ use std::collections::HashMap; pub const SHELL_ENV_KEY: &str = "SHELL"; pub const TERMUA_SHELL_ENV_KEY: &str = "TERMUA_SHELL"; pub const TERMUA_BASH_RCFILE_ENV_KEY: &str = "TERMUA_BASH_RCFILE"; -pub const TERMUA_FISH_INIT_ENV_KEY: &str = "TERMUA_FISH_INIT"; -pub const TERMUA_NU_CONFIG_ENV_KEY: &str = "TERMUA_NU_CONFIG"; -pub const TERMUA_NU_ENV_CONFIG_ENV_KEY: &str = "TERMUA_NU_ENV_CONFIG"; pub const TERMUA_PWSH_INIT_ENV_KEY: &str = "TERMUA_PWSH_INIT"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ShellKind { Bash, Zsh, - Fish, - Nu, + Pwsh, PowerShell, + Cmd, Other, } @@ -52,9 +49,9 @@ pub fn shell_kind(program: &str) -> ShellKind { match name { "bash" => ShellKind::Bash, "zsh" => ShellKind::Zsh, - "fish" => ShellKind::Fish, - "nu" | "nushell" => ShellKind::Nu, - "pwsh" | "powershell" => ShellKind::PowerShell, + "pwsh" => ShellKind::Pwsh, + "powershell" => ShellKind::PowerShell, + "cmd" => ShellKind::Cmd, _ => ShellKind::Other, } } @@ -63,9 +60,8 @@ pub fn shell_display_name(program: &str) -> String { match shell_kind(program) { ShellKind::Bash => "bash".to_string(), ShellKind::Zsh => "zsh".to_string(), - ShellKind::Fish => "fish".to_string(), - ShellKind::Nu => "nushell".to_string(), - ShellKind::PowerShell => "powershell".to_string(), + ShellKind::Pwsh | ShellKind::PowerShell => "powershell".to_string(), + ShellKind::Cmd => "cmd".to_string(), ShellKind::Other => std::path::Path::new(program.trim()) .file_name() .and_then(|s| s.to_str()) @@ -100,117 +96,37 @@ pub fn shell_integration_args_for_env(program: &str, env: &HashMap env - .get(TERMUA_FISH_INIT_ENV_KEY) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|_init| { - vec![ - "--init-command".to_string(), - "source \"$TERMUA_FISH_INIT\"".to_string(), - "--interactive".to_string(), - ] - }) - .unwrap_or_default(), - ShellKind::Nu => { - let config = env - .get(TERMUA_NU_CONFIG_ENV_KEY) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - let env_config = env - .get(TERMUA_NU_ENV_CONFIG_ENV_KEY) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - - match (config, env_config) { - (Some(config), Some(env_config)) => vec![ - "--config".to_string(), - config.to_string(), - "--env-config".to_string(), - env_config.to_string(), - "--interactive".to_string(), - ], - _ => Vec::new(), - } - } - ShellKind::PowerShell => env + ShellKind::Pwsh => env .get(TERMUA_PWSH_INIT_ENV_KEY) .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|_init| powershell_integration_args(cfg!(windows))) .unwrap_or_default(), - ShellKind::Zsh | ShellKind::Other => Vec::new(), + ShellKind::Zsh | ShellKind::PowerShell | ShellKind::Cmd | ShellKind::Other => Vec::new(), } } -fn shell_program_candidates_for_windows() -> &'static [&'static str] { - &["pwsh", "powershell", "cmd"] -} - pub fn shell_program_candidates() -> &'static [&'static str] { if cfg!(windows) { // Windows: prefer PowerShell 7+ when available, then Windows PowerShell, then cmd. - shell_program_candidates_for_windows() + &["pwsh", "powershell", "cmd"] } else if cfg!(target_os = "macos") { // macOS: default user shell is zsh on modern macOS. - &["zsh", "bash", "fish", "nu", "pwsh", "sh"] + &["zsh", "bash", "pwsh"] } else { // Linux/*nix: bash is commonly available and expected. - &["bash", "zsh", "fish", "nu", "pwsh", "sh"] + &["bash", "zsh", "pwsh"] } } -pub fn fallback_shell_program() -> &'static str { +pub fn default_shell_program() -> &'static str { if cfg!(windows) { - "powershell" + "pwsh" } else if cfg!(target_os = "macos") { "zsh" - } else if cfg!(target_os = "linux") { - "bash" } else { - "sh" - } -} - -pub fn shell_program_items_for_program_exists( - mut program_exists: impl FnMut(&str) -> bool, -) -> Vec { - shell_program_items_for_candidates( - shell_program_candidates(), - fallback_shell_program(), - &mut program_exists, - ) -} - -fn shell_program_items_for_candidates( - candidates: &[&str], - fallback: &str, - mut program_exists: impl FnMut(&str) -> bool, -) -> Vec { - let mut items = Vec::new(); - - let pwsh_exists = candidates.contains(&"pwsh") && program_exists("pwsh"); - for candidate in candidates { - if *candidate == "pwsh" { - if pwsh_exists { - items.push((*candidate).to_string()); - } - } else if *candidate == "powershell" && pwsh_exists { - continue; - } else if program_exists(candidate) { - items.push((*candidate).to_string()); - } - } - - if items.is_empty() { - items.push(fallback.to_string()); + "bash" } - - items -} - -pub fn shell_program_items() -> Vec { - shell_program_items_for_program_exists(program_exists_on_path) } #[cfg(any(windows, test))] @@ -312,74 +228,53 @@ mod tests { fn shell_kind_detects_supported_shells() { assert_eq!(shell_kind("/bin/bash"), ShellKind::Bash); assert_eq!(shell_kind("zsh"), ShellKind::Zsh); - assert_eq!(shell_kind("fish"), ShellKind::Fish); - assert_eq!(shell_kind("nu"), ShellKind::Nu); - assert_eq!(shell_kind("pwsh"), ShellKind::PowerShell); + assert_eq!(shell_kind("pwsh"), ShellKind::Pwsh); assert_eq!(shell_kind("powershell"), ShellKind::PowerShell); + assert_eq!(shell_kind("cmd"), ShellKind::Cmd); assert_eq!(shell_kind("unknown"), ShellKind::Other); } #[test] fn shell_display_name_normalizes_supported_shells() { assert_eq!(shell_display_name("/bin/bash"), "bash"); - assert_eq!(shell_display_name("nu"), "nushell"); assert_eq!(shell_display_name("pwsh"), "powershell"); assert_eq!(shell_display_name("powershell"), "powershell"); - assert_eq!(shell_display_name("/opt/bin/fish"), "fish"); - assert_eq!(shell_display_name("xonsh"), "xonsh"); } #[test] - fn shell_integration_args_build_for_fish() { - let mut env = HashMap::new(); - env.insert( - TERMUA_FISH_INIT_ENV_KEY.to_string(), - "/tmp/it doesn't matter.fish".to_string(), - ); - assert_eq!( - shell_integration_args_for_env("fish", &env), - vec![ - "--init-command".to_string(), - "source \"$TERMUA_FISH_INIT\"".to_string(), - "--interactive".to_string(), - ] - ); + fn ui_default_shell_matches_platform_policy() { + #[cfg(windows)] + assert_eq!(default_shell_program(), "pwsh"); + + #[cfg(target_os = "macos")] + assert_eq!(default_shell_program(), "zsh"); + + #[cfg(all(not(windows), not(target_os = "macos")))] + assert_eq!(default_shell_program(), "bash"); } #[test] - fn shell_integration_args_build_for_nu() { + fn shell_integration_args_build_for_pwsh() { let mut env = HashMap::new(); env.insert( - TERMUA_NU_CONFIG_ENV_KEY.to_string(), - "/tmp/config.nu".to_string(), - ); - env.insert( - TERMUA_NU_ENV_CONFIG_ENV_KEY.to_string(), - "/tmp/env.nu".to_string(), + TERMUA_PWSH_INIT_ENV_KEY.to_string(), + "/tmp/init.ps1".to_string(), ); assert_eq!( - shell_integration_args_for_env("nu", &env), - vec![ - "--config".to_string(), - "/tmp/config.nu".to_string(), - "--env-config".to_string(), - "/tmp/env.nu".to_string(), - "--interactive".to_string(), - ] + shell_integration_args_for_env("pwsh", &env), + powershell_integration_args(cfg!(windows)) ); } #[test] - fn shell_integration_args_build_for_powershell() { + fn shell_integration_args_do_not_build_for_windows_powershell() { let mut env = HashMap::new(); env.insert( TERMUA_PWSH_INIT_ENV_KEY.to_string(), "/tmp/init.ps1".to_string(), ); - assert_eq!( - shell_integration_args_for_env("pwsh", &env), - powershell_integration_args(cfg!(windows)) - ); + + assert!(shell_integration_args_for_env("powershell", &env).is_empty()); } #[test] @@ -397,16 +292,6 @@ mod tests { ); } - #[test] - fn shell_program_items_for_program_exists_filters_candidates() { - let candidates = shell_program_candidates(); - let keep_a = candidates.first().copied().unwrap(); - let keep_b = candidates.last().copied().unwrap(); - - let items = shell_program_items_for_program_exists(|name| name == keep_a || name == keep_b); - assert_eq!(items, vec![keep_a.to_string(), keep_b.to_string()]); - } - #[test] fn platform_shell_candidates_are_ordered_by_preference() { let candidates = shell_program_candidates(); @@ -421,36 +306,6 @@ mod tests { assert_eq!(candidates.first().copied(), Some("bash")); } - #[test] - fn windows_shell_candidates_prefer_pwsh_over_powershell() { - assert_eq!( - shell_program_candidates_for_windows(), - &["pwsh", "powershell", "cmd"] - ); - } - - #[test] - fn windows_shell_program_items_hide_powershell_when_pwsh_exists() { - let items = shell_program_items_for_candidates( - shell_program_candidates_for_windows(), - fallback_shell_program(), - |name| matches!(name, "pwsh" | "powershell" | "cmd"), - ); - - assert_eq!(items, vec!["pwsh".to_string(), "cmd".to_string()]); - } - - #[test] - fn windows_shell_program_items_use_powershell_when_pwsh_is_missing() { - let items = shell_program_items_for_candidates( - shell_program_candidates_for_windows(), - fallback_shell_program(), - |name| matches!(name, "powershell" | "cmd"), - ); - - assert_eq!(items, vec!["powershell".to_string(), "cmd".to_string()]); - } - #[test] fn split_pathext_ignores_empty_segments() { assert_eq!( diff --git a/crates/gpui_term/src/view/input.rs b/crates/gpui_term/src/view/input.rs new file mode 100644 index 0000000..02fe9cf --- /dev/null +++ b/crates/gpui_term/src/view/input.rs @@ -0,0 +1,54 @@ +use gpui::{Context, KeyDownEvent, ReadGlobal, Window}; + +use super::TerminalView; +use crate::{ + settings::{CursorShape, TerminalSettings}, + terminal::{Event, UserInput}, +}; + +impl TerminalView { + pub(super) fn forward_keystroke_to_terminal( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) { + let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { + let handled = term.try_keystroke( + &event.keystroke, + TerminalSettings::global(cx).option_as_meta, + ); + (handled, term.vi_mode_enabled()) + }); + + if handled { + cx.stop_propagation(); + // In terminal vi-mode, keystrokes are usually for scrollback/navigation, so don't + // force the view back to the bottom. + if !vi_mode_enabled { + self.snap_to_bottom_on_input(cx); + } + cx.emit(Event::UserInput(UserInput::Keystroke( + event.keystroke.clone(), + ))); + } + } + + pub(super) fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |terminal, _| { + terminal.set_cursor_shape(self.cursor_shape); + terminal.focus_in(); + }); + self.blink_cursors(self.blink.epoch, cx); + window.invalidate_character_coordinates(); + cx.notify(); + } + + pub(super) fn focus_out(&mut self, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |terminal, _| { + terminal.focus_out(); + terminal.set_cursor_shape(CursorShape::Hollow); + }); + self.suggestions.close(); + cx.notify(); + } +} diff --git a/crates/gpui_term/src/view/line_number.rs b/crates/gpui_term/src/view/line_number.rs index f7fc277..1274175 100644 --- a/crates/gpui_term/src/view/line_number.rs +++ b/crates/gpui_term/src/view/line_number.rs @@ -1,4 +1,4 @@ -use std::fmt::Write as _; +use std::fmt::Write; use gpui::{App, Bounds, Pixels, TextAlign, TextRun, TextStyle, Window, point, px}; use gpui_component::ActiveTheme; @@ -232,7 +232,9 @@ pub(crate) fn paint_line_numbers( #[cfg(test)] mod tests { - use super::*; + use gpui::{Pixels, px}; + + use super::{compute_line_number_layout, format_line_number}; #[test] fn reserves_minimum_gutter_without_line_numbers() { diff --git a/crates/gpui_term/src/view/mod.rs b/crates/gpui_term/src/view/mod.rs index bb5b1b6..6a142f9 100644 --- a/crates/gpui_term/src/view/mod.rs +++ b/crates/gpui_term/src/view/mod.rs @@ -1,55 +1,46 @@ -use std::{ - cmp, - collections::VecDeque, - ops::{Range, RangeInclusive}, - sync::Arc, - time::Duration, -}; +use std::{collections::VecDeque, ops::Range, sync::Arc, time::Duration}; use gpui::{ Action, AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyContext, KeyDownEvent, Keystroke, MouseButton, - MouseDownEvent, ParentElement, Pixels, PromptLevel, ReadGlobal, Render, ScrollWheelEvent, - Styled, Subscription, Window, div, px, + ParentElement, Pixels, PromptLevel, ReadGlobal, Styled, Subscription, Window, div, px, }; use gpui_common::TermuaIcon; use gpui_component::{ ActiveTheme, Icon, IconName, WindowExt, - menu::{ContextMenu, PopupMenu, PopupMenuItem}, + menu::{PopupMenu, PopupMenuItem}, notification::Notification, }; use record::{RecordingMenuEntry, recording_context_menu_entry, recording_indicator_label}; use schemars::JsonSchema; -use scrollbar::{SCROLLBAR_WIDTH, ScrollState, ScrollbarPreview, buffer_index_for_line_coord}; +use scrolling::{SCROLLBAR_WIDTH, ScrollState, ScrollbarPreview}; use serde::Deserialize; use smol::Timer; use crate::{ - Copy, DecreaseFontSize, GridPoint, HoveredWord, IncreaseFontSize, ResetFontSize, - TerminalContent, TerminalMode, - element::{ScrollbarPreviewTextElement, TerminalElement}, - point_to_viewport, + Copy, DecreaseFontSize, HoveredWord, IncreaseFontSize, ResetFontSize, TerminalContent, + TerminalMode, + element::ScrollbarPreviewTextElement, record::render_recording_indicator_label, settings::{CursorShape, TerminalBlink, TerminalSettings}, - snippet::{SnippetJump, SnippetJumpDir, SnippetSession, parse_snippet_suffix}, + snippet::{SnippetJump, SnippetJumpDir, SnippetSession}, suggestions::{ - SelectionMove, SuggestionEngine, SuggestionHistoryConfig, SuggestionItem, - SuggestionStaticConfig, compute_insert_suffix_for_line, extract_cursor_line_prefix, - extract_cursor_line_suffix, line_is_suggestion_prefix, move_selection_opt, + SuggestionEngine, SuggestionHistoryConfig, SuggestionItem, SuggestionStaticConfig, }, terminal::{ - Clear, Event, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, - ScrollToBottom, ScrollToTop, Search, SearchClose, SearchNext, SearchPaste, SearchPrevious, - SelectAll, ShowCharacterPalette, StartCastRecording, StopCastRecording, Terminal, - TerminalBounds, ToggleCastRecording, ToggleViMode, UserInput, + Clear, Event, Paste, SelectAll, ShowCharacterPalette, StartCastRecording, + StopCastRecording, Terminal, TerminalBounds, ToggleCastRecording, UserInput, }, view::search::{SearchState, render_search}, }; +mod input; pub(crate) mod line_number; pub(crate) mod record; -pub(crate) mod scrollbar; +mod render; +pub(crate) mod scrolling; pub(crate) mod search; +mod suggestions; fn format_scrollbar_preview_line_number(one_based: usize, digits: usize) -> String { let digits = digits.max(1); @@ -642,949 +633,59 @@ impl TerminalView { self.terminal.update(cx, |term, _| { term.stop_cast_recording(); }); - self.show_toast(PromptLevel::Info, "Recording stopped", None, window, cx); - } - - fn toggle_cast_recording( - &mut self, - _: &ToggleCastRecording, - window: &mut Window, - cx: &mut Context, - ) { - let recording_active = self.cast_recording_active(cx); - if recording_active { - self.stop_cast_recording(&StopCastRecording, window, cx); - } else { - self.start_cast_recording(&StartCastRecording, window, cx); - } - } - - fn open_search(&mut self, _: &Search, window: &mut Window, cx: &mut Context) { - self.search.search_open = true; - self.search.search_panel_dragging = false; - self.search.search_panel_drag_start_mouse = None; - self.search.search_panel_drag_start_pos = None; - self.search.search_expected_commit = None; - self.search.search.end(); - - // Default position: near the top, centered within the current window. - let viewport = window.viewport_size(); - let panel_w = px(520.0) - .min((viewport.width - px(24.0)).max(Pixels::ZERO)) - .max(px(320.0).min(viewport.width.max(Pixels::ZERO))); - let keep = px(32.0); - if !self.search.search_panel_pos_initialized { - let x = (viewport.width - panel_w).max(Pixels::ZERO) / 2.0; - self.search.search_panel_pos = gpui::point(x, px(72.0)); - self.search.search_panel_pos_initialized = true; - } else { - // If the window size changed since the last open, keep the panel reachable. - let mut pos = self.search.search_panel_pos; - pos.x = pos - .x - .clamp((Pixels::ZERO - panel_w) + keep, viewport.width - keep); - pos.y = pos.y.clamp(px(0.0), viewport.height - keep); - self.search.search_panel_pos = pos; - } - - window.focus(&self.focus_handle, cx); - cx.notify(); - } - - fn close_search(&mut self, _: &SearchClose, window: &mut Window, cx: &mut Context) { - self.search.search_open = false; - self.clear_scrollbar_preview(cx); - self.search.search_epoch = self.search.search_epoch.wrapping_add(1); - self.search.search.clear(); - self.search.search_ime_state = None; - self.search.search_expected_commit = None; - self.search.search_panel_dragging = false; - self.search.search_panel_drag_start_mouse = None; - self.search.search_panel_drag_start_pos = None; - - window.focus(&self.focus_handle, cx); - - self.terminal.update(cx, |term, _| { - term.set_search_query(None); - term.select_matches(&[]); - }); - cx.notify(); - } - - fn search_next(&mut self, _: &SearchNext, _window: &mut Window, cx: &mut Context) { - self.jump_search(true, cx); - } - - fn search_previous( - &mut self, - _: &SearchPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - self.jump_search(false, cx); - } - - fn jump_search(&mut self, forward: bool, cx: &mut Context) { - self.terminal.update(cx, |term, _| { - let matches_len = term.matches().len(); - if matches_len == 0 { - return; - } - - let cur = term.active_match_index().unwrap_or(0) % matches_len; - let next = if forward { - (cur + 1) % matches_len - } else { - cur.checked_sub(1).unwrap_or(matches_len - 1) - }; - term.activate_match(next); - term.jump_to_match(next); - }); - cx.notify(); - } - - fn search_paste(&mut self, _: &SearchPaste, _: &mut Window, cx: &mut Context) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - self.search.search.insert(&text); - self.schedule_search_update(cx); - } - } - - pub(crate) fn commit_search_text(&mut self, text: &str, cx: &mut Context) { - if text.is_empty() { - return; - } - if self.search.search_expected_commit.as_deref() == Some(text) { - self.search.search_expected_commit = None; - return; - } - self.search.search_expected_commit = None; - self.search.search_ime_state = None; - self.search.search.insert(text); - self.schedule_search_update(cx); - } - - pub(crate) fn set_search_marked_text( - &mut self, - text: String, - range: Option>, - cx: &mut Context, - ) { - self.search.search_ime_state = Some(ImeState { - marked_text: text, - marked_range_utf16: range, - }); - cx.notify(); - } - - pub(crate) fn clear_search_marked_text(&mut self, cx: &mut Context) { - self.search.search_ime_state = None; - cx.notify(); - } - - pub(crate) fn is_search_open(&self) -> bool { - self.search.search_open - } - - pub(crate) fn suggestions_snapshot(&self) -> Option<(Vec, Option)> { - self.suggestions.open.then(|| { - let highlighted = self - .suggestions - .hovered - .or(self.suggestions.selected) - .and_then(|highlighted| { - let last = self.suggestions.items.len().saturating_sub(1); - (!self.suggestions.items.is_empty()).then_some(highlighted.min(last)) - }); - (self.suggestions.items.clone(), highlighted) - }) - } - - pub(crate) fn close_suggestions(&mut self, cx: &mut Context) { - if !self.suggestions.open { - return; - } - self.suggestions.close(); - cx.notify(); - } - - pub(crate) fn snippet_snapshot_for_content( - &self, - content: &TerminalContent, - cursor_line_id: Option, - cx: &App, - ) -> Option { - let snippet = self.snippet.clone()?; - if !self.suggestions_eligible_for_content(content, cx) { - return None; - } - - if let Some(expected) = snippet.cursor_line_id - && cursor_line_id != Some(expected) - { - return None; - } - - Some(snippet) - } - - pub(crate) fn set_suggestions_hovered( - &mut self, - hovered: Option, - cx: &mut Context, - ) { - if !self.suggestions.open { - if self.suggestions.hovered.take().is_some() { - cx.notify(); - } - return; - } - - let hovered = hovered.and_then(|idx| { - let last = self.suggestions.items.len().saturating_sub(1); - (!self.suggestions.items.is_empty()).then_some(idx.min(last)) - }); - - if self.suggestions.hovered != hovered { - self.suggestions.hovered = hovered; - cx.notify(); - } - } - - pub(crate) fn search_marked_text_range(&self) -> Option> { - self.search - .search_ime_state - .as_ref() - .and_then(|state| state.marked_range_utf16.clone()) - } - - pub(crate) fn search_panel_pos(&self) -> gpui::Point { - self.search.search_panel_pos - } - - fn suggestions_eligible_for_content(&self, content: &TerminalContent, cx: &App) -> bool { - TerminalSettings::global(cx).suggestions_enabled - && content.display_offset == 0 - && !content.mode.contains(TerminalMode::ALT_SCREEN) - && content.selection.is_none() - && self.scroll.block_below_cursor.is_none() - } - - fn schedule_suggestions_update(&mut self, cx: &mut Context) { - let epoch = self.suggestions.epoch.wrapping_add(1); - self.suggestions.epoch = epoch; - cx.spawn(async move |this, cx| { - Timer::after(Duration::from_millis(200)).await; - let _ = this.update(cx, |this, cx| { - if this.suggestions.epoch != epoch { - return; - } - - let Some(prompt) = this.prompt_context(cx) else { - return; - }; - if !this.suggestions_eligible_for_content(&prompt.content, cx) { - this.suggestions.prompt_prefix = None; - this.suggestions.close(); - cx.notify(); - return; - } - - let Some(prompt_prefix) = this.suggestions.prompt_prefix.clone() else { - this.suggestions.close(); - cx.notify(); - return; - }; - - let line_prefix = extract_cursor_line_prefix(&prompt.content); - let input_prefix = line_prefix.strip_prefix(&prompt_prefix).unwrap_or(""); - - this.suggestions.engine.max_items = - TerminalSettings::global(cx).suggestions_max_items; - - if let Some(cfg) = cx.try_global::() - && cfg.epoch != this.suggestions.static_epoch_seen - { - this.suggestions.static_epoch_seen = cfg.epoch; - this.suggestions - .engine - .set_static_provider(cfg.provider.clone()); - } - - let items = this.suggestions.engine.suggest(input_prefix); - this.suggestions.open_with_items(items); - cx.notify(); - }); - }) - .detach(); - } - - fn accept_selected_suggestion( - &mut self, - content: &TerminalContent, - cursor_line_id: Option, - cx: &mut Context, - ) -> bool { - if !self.suggestions.open { - return false; - } - - let Some(selected) = self.suggestions.selected else { - return false; - }; - self.accept_suggestion_at_index(selected, content, cursor_line_id, cx) - } - - pub(crate) fn accept_suggestion_at_index( - &mut self, - index: usize, - content: &TerminalContent, - cursor_line_id: Option, - cx: &mut Context, - ) -> bool { - if !self.suggestions.open { - return false; - } - - let Some(item) = self.suggestions.items.get(index).cloned() else { - return false; - }; - - let line_prefix = extract_cursor_line_prefix(content); - let Some((input_prefix, suffix_template)) = compute_insert_suffix_for_line( - &line_prefix, - self.suggestions.prompt_prefix.as_deref(), - &item.full_text, - ) else { - return false; - }; - - let line_suffix = extract_cursor_line_suffix(content); - if !line_suffix.trim().is_empty() { - let combined_line = format!("{input_prefix}{line_suffix}"); - if line_is_suggestion_prefix(&combined_line, &item.full_text) { - return false; - } - } - - let mut suffix_rendered = suffix_template.clone(); - let mut snippet_session: Option = None; - let mut initial_move_left = 0usize; - - if let Some(snippet) = parse_snippet_suffix(&suffix_template) { - suffix_rendered = snippet.rendered; - - // `$0` is a cursor position, not an editable placeholder. Avoid entering snippet mode - // if there are no non-zero tabstops. - if snippet.tabstops.iter().any(|t| t.index != 0) { - let mut session = SnippetSession::new(suffix_rendered.clone(), snippet.tabstops); - session.cursor_line_id = cursor_line_id; - session.start_point = content.cursor.point; - session.active = 0; - - let target_end = session - .tabstops - .first() - .map(|t| t.range_chars.end) - .unwrap_or(session.inserted_len_chars); - - initial_move_left = session.inserted_len_chars.saturating_sub(target_end); - session.cursor_offset_chars = target_end; - session.selected = true; - snippet_session = Some(session); - } - } - - self.snap_to_bottom_on_input(cx); - let alt_is_meta = TerminalSettings::global(cx).option_as_meta; - let left = Keystroke::parse("left").unwrap(); - - let suffix = suffix_rendered.into_bytes(); - self.terminal.update(cx, move |term, _| { - term.input(suffix); - for _ in 0..initial_move_left { - term.try_keystroke(&left, alt_is_meta); - } - }); - self.snippet = snippet_session; - self.suggestions.close(); - true - } - - fn schedule_search_update(&mut self, cx: &mut Context) { - let epoch = self.search.search_epoch.wrapping_add(1); - self.search.search_epoch = epoch; - cx.spawn(async move |this, cx| { - Timer::after(Duration::from_millis(150)).await; - let _ = this.update(cx, |this, cx| { - if !this.search.search_open || this.search.search_epoch != epoch { - return; - } - - // Only treat all-whitespace as empty; otherwise keep the query exactly as typed. - let q = this.search.search.text().to_string(); - this.terminal.update(cx, |term, _| { - if q.chars().all(|c| c.is_whitespace()) { - term.set_search_query(None); - } else { - term.set_search_query(Some(q)); - if !term.matches().is_empty() { - term.activate_match(0); - } - } - }); - cx.notify(); - }); - }) - .detach(); - } - - pub(crate) fn search_cursor_utf16(&self) -> usize { - self.search.search.cursor_utf16() - } - - fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |term, _| term.select_all()); - cx.notify(); - } - - fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |term, _| term.clear()); - cx.notify(); - } - - fn reset_font_size(&mut self, _: &ResetFontSize, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |_, cx| { - cx.global_mut::().font_size = px(15.); - }); - cx.notify(); - } - - fn increase_font_size(&mut self, _: &IncreaseFontSize, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |_, cx| { - let font_size = cx.global::().font_size; - if font_size >= px(100.) { - return; - } - cx.global_mut::().font_size += px(1.); - }); - cx.notify(); - } - - fn decrease_font_size(&mut self, _: &DecreaseFontSize, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |_, cx| { - let font_size = cx.global::().font_size; - if font_size <= px(5.) { - return; - } - cx.global_mut::().font_size -= px(1.); - }); - cx.notify(); - } - - fn max_scroll_top(&self, cx: &App) -> Pixels { - let terminal = self.terminal.read(cx); - - let Some(block) = self.scroll.block_below_cursor.as_ref() else { - return Pixels::ZERO; - }; - - let content = terminal.last_content(); - let line_height = content.terminal_bounds.line_height; - let viewport_lines = terminal.viewport_lines(); - let cursor = - point_to_viewport(content.display_offset, content.cursor.point).unwrap_or_default(); - let max_scroll_top_in_lines = - (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1)); - - max_scroll_top_in_lines as f32 * line_height - } - - /// Zed-like behavior: if the user is looking at history (terminal scrollback) or has scrolled - /// away from the live block content, any input that is forwarded to the PTY should snap the - /// view back to the bottom. - fn snap_to_bottom_on_input(&mut self, cx: &mut Context) { - let TerminalScrollState { - display_offset, - line_height, - .. - } = self.terminal_scroll_state(cx); - - // History scrolling: ensure we immediately return to the live viewport on any PTY input. - // (Some input paths, like `try_keystroke`, don't go through backend `input()`.) - if display_offset != 0 { - self.terminal.update(cx, |term, _| term.scroll_to_bottom()); - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = true; - cx.notify(); - return; - } - - // Block-below-cursor extra scroll space (e.g. prompt block). Only applies in live view. - if self.scroll.block_below_cursor.is_some() { - let max = self.max_scroll_top(cx); - let at_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; - - if !at_bottom { - self.scroll.scroll_top = max; - } - } - - // Typing implies "follow output" unless the user scrolls away again. - self.scroll.stick_to_bottom = true; - cx.notify(); - } - - pub(crate) fn set_mouse_left_down_in_terminal(&mut self, down: bool) { - self.scroll.mouse_left_down_in_terminal = down; - } - - pub(crate) fn mouse_left_down_in_terminal(&self) -> bool { - self.scroll.mouse_left_down_in_terminal - } - - pub(crate) fn scroll_wheel( - &mut self, - event: &ScrollWheelEvent, - window: &mut Window, - cx: &mut Context, - ) { - // Scroll-wheel usage should reveal the overlay scrollbar briefly, even if the pointer - // isn't within the scrollbar lane. - // - // Note: This is kept view-local so we can avoid allocating dedicated layout space. - // The element decides whether to paint the scrollbar based on this flag. - // (Timer-based auto-hide happens in `reveal_scrollbar_for_scroll`.) - // - // We don't require focus checks here; callers already gate on focus. - self.reveal_scrollbar_for_scroll(window, cx); - - let TerminalScrollState { - is_remote_mirror, - display_offset, - line_height, - } = self.terminal_scroll_state(cx); - - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_wheel(event)); - return; - } - - if self.scroll.block_below_cursor.is_some() && display_offset == 0 { - let y_delta = event.delta.pixel_delta(line_height).y; - if y_delta < Pixels::ZERO || self.scroll.scroll_top > Pixels::ZERO { - let max = self.max_scroll_top(cx); - self.scroll.scroll_top = cmp::max( - Pixels::ZERO, - cmp::min(self.scroll.scroll_top - y_delta, max), - ); - self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; - cx.notify(); - return; - } - } - // Scrolling the terminal history should never keep a block-scroll offset. - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; - self.terminal.update(cx, |term, _| term.scroll_wheel(event)); - } - - pub(crate) fn scrollbar_dragging(&self) -> bool { - self.scroll.scrollbar_dragging - } - - pub(crate) fn scrollbar_hovered(&self) -> bool { - self.scroll.scrollbar_hovered - } - - pub(crate) fn scrollbar_revealed(&self) -> bool { - self.scroll.scrollbar_revealed - } - - pub(crate) fn set_scrollbar_hovered(&mut self, hovered: bool, cx: &mut Context) { - if self.scroll.scrollbar_hovered != hovered { - self.scroll.scrollbar_hovered = hovered; - cx.notify(); - } - } - - fn reveal_scrollbar_for_scroll(&mut self, _window: &mut Window, cx: &mut Context) { - if !TerminalSettings::global(cx).show_scrollbar { - return; - } - self.scroll.scrollbar_revealed = true; - self.scroll.scrollbar_reveal_epoch = self.scroll.scrollbar_reveal_epoch.wrapping_add(1); - let epoch = self.scroll.scrollbar_reveal_epoch; - cx.notify(); - - // Auto-hide after a short delay. If the user is hovering/dragging at that time, the - // scrollbar stays visible due to those signals (not this temporary reveal flag). - cx.spawn(async move |this, cx| { - Timer::after(Duration::from_millis(900)).await; - let _ = this.update(cx, |this, cx| { - if this.scroll.scrollbar_reveal_epoch != epoch { - return; - } - if this.scroll.scrollbar_revealed { - this.scroll.scrollbar_revealed = false; - cx.notify(); - } - }); - }) - .detach(); - } - - pub(crate) fn scroll_top(&self) -> Pixels { - self.scroll.scroll_top - } - - pub(crate) fn scrollbar_virtual_offset(&self) -> Option { - self.scroll.scrollbar_virtual_offset - } - - pub(crate) fn scrollbar_drag_origin(&self) -> Option<(Pixels, usize)> { - Some(( - self.scroll.scrollbar_drag_start_y?, - self.scroll.scrollbar_drag_start_offset?, - )) - } - - pub(crate) fn set_scrollbar_drag_origin(&mut self, mouse_y: Pixels, offset: usize) { - self.scroll.scrollbar_drag_start_y = Some(mouse_y); - self.scroll.scrollbar_drag_start_offset = Some(offset); - } - - pub(crate) fn begin_scrollbar_drag(&mut self, mouse_y: Pixels, cx: &mut Context) { - self.scroll.scrollbar_dragging = true; - let current = { - let terminal = self.terminal.read(cx); - terminal.last_content().display_offset - }; - self.scroll.scrollbar_virtual_offset = Some(current); - self.scroll.scrollbar_last_target_offset = Some(current); - self.set_scrollbar_drag_origin(mouse_y, current); - } - - pub(crate) fn end_scrollbar_drag(&mut self) { - self.scroll.scrollbar_dragging = false; - self.scroll.scrollbar_last_target_offset = None; - self.scroll.scrollbar_virtual_offset = None; - self.scroll.scrollbar_drag_start_y = None; - self.scroll.scrollbar_drag_start_offset = None; - } - - pub(crate) fn scrollbar_preview(&self) -> Option<&ScrollbarPreview> { - self.scroll.scrollbar_preview.as_ref() - } - - pub(crate) fn clear_scrollbar_preview(&mut self, cx: &mut Context) { - if self.scroll.scrollbar_preview.take().is_some() { - cx.notify(); - } - } - - pub(crate) fn set_scrollbar_preview_for_match( - &mut self, - match_index: usize, - anchor: gpui::Point, - cx: &mut Context, - ) { - let Some((start, cols, rows, cells, match_range)) = (|| { - let terminal = self.terminal.read(cx); - let matches = terminal.matches(); - if match_index >= matches.len() { - return None; - } - - let total_lines = terminal.total_lines(); - let viewport_lines = terminal.viewport_lines(); - let match_range = matches[match_index].clone(); - - let start_line_coord = match_range.start().line; - let end_line_coord = match_range.end().line; - let start_line_from_top = - buffer_index_for_line_coord(total_lines, viewport_lines, start_line_coord); - let end_line_from_top = - buffer_index_for_line_coord(total_lines, viewport_lines, end_line_coord); - - let context_above = 3usize; - let total = 7usize; - let start = start_line_from_top.saturating_sub(context_above); - - let (cols, rows, cells) = terminal.preview_cells_from_top(start, total); - if rows == 0 || cells.is_empty() { - return None; - } - - // Convert the match range into preview-local coordinates (preview starts at line 0). - let local_start_line = start_line_from_top.saturating_sub(start); - let local_end_line = end_line_from_top.saturating_sub(start); - let local_range = RangeInclusive::new( - GridPoint::new(local_start_line as i32, match_range.start().column), - GridPoint::new(local_end_line as i32, match_range.end().column), - ); - - Some((start, cols, rows, cells, local_range)) - })() else { - self.clear_scrollbar_preview(cx); - return; - }; - - // Avoid re-fetching preview text while the pointer moves within the same marker. - if let Some(prev) = self.scroll.scrollbar_preview.as_mut() - && prev.match_index == match_index - && prev.start_line_from_top == start - { - prev.anchor = anchor; - cx.notify(); - return; - } - - self.scroll.scrollbar_preview = Some(ScrollbarPreview { - match_index, - anchor, - start_line_from_top: start, - cols, - rows, - cells, - match_range, - }); - cx.notify(); - } - - pub(crate) fn apply_scrollbar_target_offset( - &mut self, - target_offset: usize, - cx: &mut Context, - ) { - if self.scroll.scrollbar_last_target_offset == Some(target_offset) { - return; - } - self.scroll.scrollbar_last_target_offset = Some(target_offset); - let current = self.scroll.scrollbar_virtual_offset.unwrap_or_else(|| { - let terminal = self.terminal.read(cx); - terminal.last_content().display_offset - }); - self.scroll_to_display_offset_from_current(current, target_offset, cx); - self.scroll.scrollbar_virtual_offset = Some(target_offset); - } - - fn scroll_to_display_offset_from_current( - &mut self, - current_offset: usize, - target_offset: usize, - cx: &mut Context, - ) { - let (is_remote_mirror, max_offset) = { - let terminal = self.terminal.read(cx); - let total_lines = terminal.total_lines(); - let viewport_lines = terminal.viewport_lines(); - ( - terminal.is_remote_mirror(), - total_lines.saturating_sub(viewport_lines), - ) - }; - - let current_offset = current_offset.min(max_offset); - let target_offset = target_offset.min(max_offset); - if target_offset == current_offset { - if !is_remote_mirror { - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = target_offset == 0; - } - return; - } - - if !is_remote_mirror { - // Scrolling the terminal history should never keep the extra "block below cursor" - // scroll. - self.scroll.scroll_top = Pixels::ZERO; - } - - self.terminal.update(cx, |term, _| { - if target_offset == 0 { - term.scroll_to_bottom(); - } else if target_offset == max_offset { - term.scroll_to_top(); - } else if target_offset > current_offset { - term.scroll_up_by(target_offset - current_offset); - } else { - term.scroll_down_by(current_offset - target_offset); - } - }); - - if !is_remote_mirror { - self.scroll.stick_to_bottom = target_offset == 0; - cx.notify(); - } - } - - // `scroll_to_display_offset_from_current` is the only implementation we need right now. - - fn scroll_line_up(&mut self, _: &ScrollLineUp, window: &mut Window, cx: &mut Context) { - self.reveal_scrollbar_for_scroll(window, cx); - let TerminalScrollState { - is_remote_mirror, - display_offset, - line_height, - } = self.terminal_scroll_state(cx); - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_line_up()); - return; - } - if self.scroll.block_below_cursor.is_some() - && display_offset == 0 - && self.scroll.scroll_top > Pixels::ZERO - { - self.scroll.scroll_top = cmp::max(self.scroll.scroll_top - line_height, Pixels::ZERO); - let max = self.max_scroll_top(cx); - self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; - return; - } - - self.terminal.update(cx, |term, _| term.scroll_line_up()); - // Terminal scrollback and block scrolling are mutually exclusive. - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; - cx.notify(); - } - - fn scroll_line_down( - &mut self, - _: &ScrollLineDown, - window: &mut Window, - cx: &mut Context, - ) { - self.reveal_scrollbar_for_scroll(window, cx); - let TerminalScrollState { - is_remote_mirror, - display_offset, - line_height, - } = self.terminal_scroll_state(cx); - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_line_down()); - return; - } - if self.scroll.block_below_cursor.is_some() && display_offset == 0 { - let max_scroll_top = self.max_scroll_top(cx); - if self.scroll.scroll_top < max_scroll_top { - self.scroll.scroll_top = - cmp::min(self.scroll.scroll_top + line_height, max_scroll_top); - } - self.scroll.stick_to_bottom = - self.scroll.scroll_top + line_height / 2.0 >= max_scroll_top; - return; - } - - self.terminal.update(cx, |term, _| term.scroll_line_down()); - // Terminal scrollback and block scrolling are mutually exclusive. - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; - cx.notify(); - } - - fn scroll_page_up(&mut self, _: &ScrollPageUp, window: &mut Window, cx: &mut Context) { - self.reveal_scrollbar_for_scroll(window, cx); - let (is_remote_mirror, line_height, viewport_lines) = { - let terminal = self.terminal.read(cx); - ( - terminal.is_remote_mirror(), - terminal.last_content().terminal_bounds.line_height(), - terminal.viewport_lines(), - ) - }; - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_page_up()); - return; - } - if self.scroll.scroll_top == Pixels::ZERO { - self.terminal.update(cx, |term, _| term.scroll_page_up()); - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; - } else { - let visible_block_lines = (self.scroll.scroll_top / line_height) as usize; - let visible_content_lines = viewport_lines - visible_block_lines; - - if visible_block_lines >= viewport_lines { - self.scroll.scroll_top = - ((visible_block_lines - viewport_lines) as f32) * line_height; - } else { - self.scroll.scroll_top = px(0.); - self.terminal - .update(cx, |term, _| term.scroll_up_by(visible_content_lines)); - } - self.scroll.stick_to_bottom = - self.scroll.scroll_top + line_height / 2.0 >= self.max_scroll_top(cx); - } - cx.notify(); + self.show_toast(PromptLevel::Info, "Recording stopped", None, window, cx); } - fn scroll_page_down( + fn toggle_cast_recording( &mut self, - _: &ScrollPageDown, + _: &ToggleCastRecording, window: &mut Window, cx: &mut Context, ) { - self.reveal_scrollbar_for_scroll(window, cx); - let TerminalScrollState { - is_remote_mirror, - display_offset, - .. - } = self.terminal_scroll_state(cx); - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_page_down()); - return; - } - self.terminal.update(cx, |term, _| term.scroll_page_down()); - // Scrolling the terminal history should not apply block scrolling offsets. - // `scroll_top` is only meaningful while we're at the live view. - if self.scroll.block_below_cursor.is_some() && display_offset == 0 { - self.scroll.scroll_top = self.max_scroll_top(cx); - self.scroll.stick_to_bottom = true; + let recording_active = self.cast_recording_active(cx); + if recording_active { + self.stop_cast_recording(&StopCastRecording, window, cx); } else { - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; + self.start_cast_recording(&StartCastRecording, window, cx); } + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |term, _| term.select_all()); cx.notify(); } - fn scroll_to_top(&mut self, _: &ScrollToTop, window: &mut Window, cx: &mut Context) { - self.reveal_scrollbar_for_scroll(window, cx); - let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror; - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_to_top()); - return; - } - self.terminal.update(cx, |term, _| term.scroll_to_top()); - self.scroll.scroll_top = Pixels::ZERO; - self.scroll.stick_to_bottom = false; + fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |term, _| term.clear()); cx.notify(); } - fn scroll_to_bottom( - &mut self, - _: &ScrollToBottom, - window: &mut Window, - cx: &mut Context, - ) { - self.reveal_scrollbar_for_scroll(window, cx); - let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror; - if is_remote_mirror { - self.terminal.update(cx, |term, _| term.scroll_to_bottom()); - return; - } - self.terminal.update(cx, |term, _| term.scroll_to_bottom()); - if self.scroll.block_below_cursor.is_some() { - self.scroll.scroll_top = self.max_scroll_top(cx); - } else { - self.scroll.scroll_top = Pixels::ZERO; - } - self.scroll.stick_to_bottom = true; + fn reset_font_size(&mut self, _: &ResetFontSize, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |_, cx| { + cx.global_mut::().font_size = px(15.); + }); + cx.notify(); + } + + fn increase_font_size(&mut self, _: &IncreaseFontSize, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |_, cx| { + let font_size = cx.global::().font_size; + if font_size >= px(100.) { + return; + } + cx.global_mut::().font_size += px(1.); + }); cx.notify(); } - fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |term, _| term.toggle_vi_mode()); + fn decrease_font_size(&mut self, _: &DecreaseFontSize, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |_, cx| { + let font_size = cx.global::().font_size; + if font_size <= px(5.) { + return; + } + cx.global_mut::().font_size -= px(1.); + }); cx.notify(); } @@ -1910,582 +1011,6 @@ impl TerminalView { self.forward_keystroke_to_terminal(event, cx); } - fn handle_search_overlay_key_down_for_terminal_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) -> bool { - match self.handle_search_overlay_key_down(event, window, cx) { - SearchOverlayKeyDown::NotOpen => false, - SearchOverlayKeyDown::Return => true, - SearchOverlayKeyDown::StopAndReturn => { - // Search is a overlay; don't forward keystrokes to the terminal. - cx.stop_propagation(); - true - } - } - } - - fn handle_snippet_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) -> bool { - if self.snippet.is_none() { - return false; - } - - let eligible = self - .prompt_context(cx) - .is_some_and(|prompt| self.snippet_prompt_is_eligible(&prompt, cx)); - - if !eligible { - self.snippet = None; - return false; - } - - // Any newline ends the snippet session. - if event.keystroke.key.as_str() == "enter" { - self.snippet = None; - return false; - } - - let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| { - !ch.is_empty() - && !event.keystroke.is_ime_in_progress() - && !event.keystroke.modifiers.control - && !event.keystroke.modifiers.platform - && !event.keystroke.modifiers.function - && !event.keystroke.modifiers.alt - }); - if is_plain_text - && !matches!(event.keystroke.key.as_str(), "tab" | "escape") - && let Some(ch) = event.keystroke.key_char.as_deref() - && !ch.is_empty() - { - // Snippet placeholder "selection" is local UI state; terminal line editors do not - // support replacing highlighted ranges. Treat character input as an explicit - // commit so we can delete/replace the active placeholder and keep placeholder - // highlight ranges in sync. - self.commit_text(ch, cx); - cx.notify(); - cx.stop_propagation(); - return true; - } - - let Some(session) = self.snippet.as_mut() else { - return false; - }; - - if event.keystroke.key.as_str() == "backspace" { - if session.selected { - let deleted_chars = session.delete_active_placeholder(); - session.selected = false; - - if deleted_chars > 0 { - let alt_is_meta = TerminalSettings::global(cx).option_as_meta; - let backspace = Keystroke::parse("backspace").unwrap(); - self.terminal.update(cx, move |term, _| { - for _ in 0..deleted_chars { - term.try_keystroke(&backspace, alt_is_meta); - } - }); - cx.notify(); - cx.stop_propagation(); - return true; - } - } else { - let deleted = session.backspace_one_in_active_placeholder(); - if deleted { - let alt_is_meta = TerminalSettings::global(cx).option_as_meta; - let backspace = Keystroke::parse("backspace").unwrap(); - self.terminal.update(cx, move |term, _| { - term.try_keystroke(&backspace, alt_is_meta); - }); - cx.notify(); - cx.stop_propagation(); - return true; - } - } - return false; - } - - // Any unexpected navigation/editing key cancels snippet mode to avoid - // desync with the remote line editor state. - let key = event.keystroke.key.as_str(); - let cancel = event.keystroke.modifiers.control - || event.keystroke.modifiers.platform - || event.keystroke.modifiers.function - || event.keystroke.modifiers.alt - || matches!( - key, - "left" - | "right" - | "up" - | "down" - | "home" - | "end" - | "pageup" - | "pagedown" - | "delete" - ); - if cancel { - self.snippet = None; - } - - false - } - - fn handle_suggestions_key_down( - &mut self, - event: &KeyDownEvent, - cx: &mut Context, - ) -> bool { - // Suggestions are intentionally conservative: remote/SSH sessions have no shell - // integration, so we only show/accept append-only hints in shell-like contexts. - if !TerminalSettings::global(cx).suggestions_enabled || self.snippet.is_some() { - return false; - } - - let Some(prompt) = self.prompt_context(cx) else { - return false; - }; - if !self.suggestions_eligible_for_content(&prompt.content, cx) { - self.suggestions.prompt_prefix = None; - self.suggestions.close(); - return false; - } - - match event.keystroke.key.as_str() { - "escape" if self.suggestions.open => { - self.suggestions.close(); - cx.notify(); - cx.stop_propagation(); - true - } - "up" if self.suggestions.open => { - self.suggestions.selected = move_selection_opt( - self.suggestions.selected, - self.suggestions.items.len(), - SelectionMove::Up, - ); - cx.notify(); - cx.stop_propagation(); - true - } - "down" if self.suggestions.open => { - self.suggestions.selected = move_selection_opt( - self.suggestions.selected, - self.suggestions.items.len(), - SelectionMove::Down, - ); - cx.notify(); - cx.stop_propagation(); - true - } - "enter" => { - if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) { - cx.stop_propagation(); - return true; - } - - if let Some(prompt_prefix) = self.suggestions.prompt_prefix.take() { - let line_prefix = extract_cursor_line_prefix(&prompt.content); - let input = line_prefix - .strip_prefix(&prompt_prefix) - .unwrap_or("") - .trim() - .to_string(); - if !input.is_empty() { - self.queue_command_for_history(input, cx); - } - } - self.suggestions.close(); - false - } - "right" => { - if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) { - cx.stop_propagation(); - return true; - } - false - } - "backspace" => { - if self.suggestions.prompt_prefix.is_none() { - self.suggestions.prompt_prefix = - Some(extract_cursor_line_prefix(&prompt.content)); - } - self.schedule_suggestions_update(cx); - false - } - _ => { - let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| { - !ch.is_empty() - && !event.keystroke.is_ime_in_progress() - && !event.keystroke.modifiers.control - && !event.keystroke.modifiers.platform - && !event.keystroke.modifiers.function - && !event.keystroke.modifiers.alt - }); - - if is_plain_text { - if self.suggestions.prompt_prefix.is_none() { - self.suggestions.prompt_prefix = - Some(extract_cursor_line_prefix(&prompt.content)); - } - self.schedule_suggestions_update(cx); - } else if self.suggestions.open { - self.suggestions.close(); - } - false - } - } - } - - fn forward_keystroke_to_terminal(&mut self, event: &KeyDownEvent, cx: &mut Context) { - let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { - let handled = term.try_keystroke( - &event.keystroke, - TerminalSettings::global(cx).option_as_meta, - ); - (handled, term.vi_mode_enabled()) - }); - - if handled { - cx.stop_propagation(); - // In terminal vi-mode, keystrokes are usually for scrollback/navigation, so don't - // force the view back to the bottom. - if !vi_mode_enabled { - self.snap_to_bottom_on_input(cx); - } - cx.emit(Event::UserInput(UserInput::Keystroke( - event.keystroke.clone(), - ))); - } - } - - fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |terminal, _| { - terminal.set_cursor_shape(self.cursor_shape); - terminal.focus_in(); - }); - self.blink_cursors(self.blink.epoch, cx); - window.invalidate_character_coordinates(); - cx.notify(); - } - - fn focus_out(&mut self, _: &mut Window, cx: &mut Context) { - self.terminal.update(cx, |terminal, _| { - terminal.focus_out(); - terminal.set_cursor_shape(CursorShape::Hollow); - }); - self.suggestions.close(); - cx.notify(); - } -} - -impl Render for TerminalView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let terminal_handle = self.terminal.clone(); - let terminal_view_handle = cx.entity(); - - self.sync_scroll_for_render(cx); - let focused = self.focus_handle.is_focused(window); - - let mut root = self.terminal_view_root_base(cx); - root = self.terminal_view_root_mouse_handlers(root, cx); - root = root.child(self.terminal_view_inner_wrapper( - terminal_handle, - terminal_view_handle.clone(), - focused, - cx, - )); - root = root.children(self.collect_overlay_elements(window, cx)); - - if !self.context_menu_enabled { - return root.into_any_element(); - } - - let context_menu_enabled = self.context_menu_enabled; - let action_context = self.focus_handle.clone(); - let menu_terminal_handle = self.terminal.clone(); - let terminal_view = terminal_view_handle.clone(); - let context_menu_provider = self.context_menu_provider.clone(); - - ContextMenu::new("terminal-view-context-menu", root) - .menu(move |menu, window, cx| { - Self::build_terminal_context_menu( - context_menu_enabled, - action_context.clone(), - menu_terminal_handle.clone(), - terminal_view.clone(), - context_menu_provider.clone(), - menu, - window, - cx, - ) - }) - .into_any_element() - } -} - -impl TerminalView { - fn terminal_view_root_base(&mut self, cx: &mut Context) -> gpui::Stateful { - div() - .id("terminal-view") - .size_full() - .relative() - .track_focus(&self.focus_handle(cx)) - .key_context(self.dispatch_context(cx)) - .on_action(cx.listener(TerminalView::send_text)) - .on_action(cx.listener(TerminalView::send_keystroke)) - .on_action(cx.listener(TerminalView::open_search)) - .on_action(cx.listener(TerminalView::search_next)) - .on_action(cx.listener(TerminalView::search_previous)) - .on_action(cx.listener(TerminalView::close_search)) - .on_action(cx.listener(TerminalView::search_paste)) - .on_action(cx.listener(TerminalView::copy)) - .on_action(cx.listener(TerminalView::paste)) - .on_action(cx.listener(TerminalView::clear)) - .on_action(cx.listener(TerminalView::reset_font_size)) - .on_action(cx.listener(TerminalView::increase_font_size)) - .on_action(cx.listener(TerminalView::decrease_font_size)) - .on_action(cx.listener(TerminalView::scroll_line_up)) - .on_action(cx.listener(TerminalView::scroll_line_down)) - .on_action(cx.listener(TerminalView::scroll_page_up)) - .on_action(cx.listener(TerminalView::scroll_page_down)) - .on_action(cx.listener(TerminalView::scroll_to_top)) - .on_action(cx.listener(TerminalView::scroll_to_bottom)) - .on_action(cx.listener(TerminalView::toggle_vi_mode)) - .on_action(cx.listener(TerminalView::show_character_palette)) - .on_action(cx.listener(TerminalView::select_all)) - .on_action(cx.listener(TerminalView::start_cast_recording)) - .on_action(cx.listener(TerminalView::stop_cast_recording)) - .on_action(cx.listener(TerminalView::toggle_cast_recording)) - .on_key_down(cx.listener(Self::key_down)) - } - - fn terminal_view_root_mouse_handlers( - &mut self, - root: gpui::Stateful, - cx: &mut Context, - ) -> gpui::Stateful { - let root = root.on_mouse_down( - MouseButton::Left, - cx.listener(|this, event: &MouseDownEvent, window, cx| { - // Treat the left gutter (line numbers/padding) as UI chrome: allow selecting - // command blocks there rather than starting a terminal selection. - let content_bounds = { - let terminal = this.terminal.read(cx); - terminal.last_content().terminal_bounds.bounds - }; - if event.position.x < content_bounds.origin.x { - this.close_suggestions(cx); - this.select_command_block_at_y( - event.position.y, - event.modifiers.shift, - window, - cx, - ); - cx.stop_propagation(); - } - }), - ); - - if !self.context_menu_enabled { - return root; - } - - root.on_mouse_down( - MouseButton::Right, - cx.listener(|this, event: &MouseDownEvent, window, cx| { - this.close_suggestions(cx); - - // We treat the left gutter (outside the terminal content bounds) as "UI - // chrome", not part of the terminal application. Allow the - // context menu there even when the terminal is in mouse - // mode (e.g. vim/tmux). - let (content_bounds, mouse_mode_enabled, has_selection) = { - let terminal = this.terminal.read(cx); - ( - terminal.last_content().terminal_bounds.bounds, - terminal.mouse_mode(event.modifiers.shift), - terminal.last_content().selection.is_some(), - ) - }; - let clicked_in_gutter = event.position.x < content_bounds.origin.x; - if clicked_in_gutter { - // Pre-select the block (if any) so context-menu actions apply. - this.select_command_block_at_y( - event.position.y, - event.modifiers.shift, - window, - cx, - ); - return; - } - - // When the terminal is in mouse mode (e.g. vim/tmux), don't open the context - // menu; let the application handle right clicks. - if mouse_mode_enabled { - cx.stop_propagation(); - return; - } - - if !has_selection { - this.terminal.update(cx, |terminal, _| { - terminal.select_word_at_event_position(event); - }); - window.refresh(); - } - }), - ) - } - - fn terminal_view_inner_wrapper( - &mut self, - terminal_handle: Entity, - terminal_view_handle: Entity, - focused: bool, - cx: &mut Context, - ) -> gpui::Stateful { - // NOTE: Keep a wrapper div around `TerminalElement`; without it the terminal - // element can interfere with overlay UI (context menu, etc). - div() - .id("terminal-view-inner") - .size_full() - .relative() - .child(TerminalElement::new( - terminal_handle, - terminal_view_handle, - self.focus_handle.clone(), - focused, - self.should_show_cursor(focused, cx), - self.scroll.block_below_cursor.clone(), - )) - } - - fn handle_search_overlay_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) -> SearchOverlayKeyDown { - if !self.search.search_open { - return SearchOverlayKeyDown::NotOpen; - } - - match event.keystroke.key.as_str() { - "escape" => { - self.close_search(&SearchClose, window, cx); - } - "enter" => { - self.jump_search(!event.keystroke.modifiers.shift, cx); - } - "left" => self.search_overlay_move_cursor(SearchOverlayMove::Left, event, cx), - "right" => self.search_overlay_move_cursor(SearchOverlayMove::Right, event, cx), - "home" => self.search_overlay_move_cursor(SearchOverlayMove::Home, event, cx), - "end" => self.search_overlay_move_cursor(SearchOverlayMove::End, event, cx), - "backspace" => self.search_overlay_delete(SearchOverlayDelete::Prev, event, cx), - "delete" => self.search_overlay_delete(SearchOverlayDelete::Next, event, cx), - // Common terminal muscle memory: cmd/ctrl+a moves to start of the input. - "a" if event.keystroke.modifiers.secondary() => { - self.search.search.home(); - cx.notify(); - } - // Support cmd/ctrl+v paste into the query even if keybinding dispatch is skipped. - "v" if event.keystroke.modifiers.secondary() => { - self.search_paste(&SearchPaste, window, cx); - } - // Support cmd/ctrl+g next/prev even if keybinding dispatch is skipped. - "g" if event.keystroke.modifiers.secondary() => { - self.jump_search(!event.keystroke.modifiers.shift, cx); - } - _ => { - // Text input (including IME) is handled via the InputHandler installed by the - // terminal element while the search is open. - // - // However, on some platforms plain latin text does *not* go through the IME - // callbacks; in that case we fall back to `key_char`. - if self.search_overlay_is_composing(event) { - // IME is actively composing; don't insert raw keystrokes. - return SearchOverlayKeyDown::Return; - } - - if event.keystroke.modifiers.control - || event.keystroke.modifiers.platform - || event.keystroke.modifiers.function - || event.keystroke.modifiers.alt - { - return SearchOverlayKeyDown::Return; - } - - if let Some(ch) = event.keystroke.key_char.as_ref() - && !ch.is_empty() - { - self.search.search_expected_commit = Some(ch.clone()); - self.search.search.insert(ch); - self.schedule_search_update(cx); - } - } - } - - SearchOverlayKeyDown::StopAndReturn - } - - fn search_overlay_is_composing(&self, event: &KeyDownEvent) -> bool { - self.search_overlay_has_marked_text() || event.keystroke.is_ime_in_progress() - } - - fn search_overlay_has_marked_text(&self) -> bool { - self.search - .search_ime_state - .as_ref() - .is_some_and(|ime| !ime.marked_text.is_empty()) - } - - fn search_overlay_move_cursor( - &mut self, - movement: SearchOverlayMove, - event: &KeyDownEvent, - cx: &mut Context, - ) { - if self.search_overlay_is_composing(event) { - // Let the IME handle caret movement inside an active composition. - return; - } - - match movement { - SearchOverlayMove::Left => self.search.search.move_left(), - SearchOverlayMove::Right => self.search.search.move_right(), - SearchOverlayMove::Home => self.search.search.home(), - SearchOverlayMove::End => self.search.search.end(), - } - cx.notify(); - } - - fn search_overlay_delete( - &mut self, - delete: SearchOverlayDelete, - event: &KeyDownEvent, - cx: &mut Context, - ) { - let should_ignore = match delete { - SearchOverlayDelete::Prev => self.search_overlay_has_marked_text(), - SearchOverlayDelete::Next => self.search_overlay_is_composing(event), - }; - if should_ignore { - // Let the platform IME drive composition edits via the InputHandler. - return; - } - - let changed = match delete { - SearchOverlayDelete::Prev => self.search.search.delete_prev(), - SearchOverlayDelete::Next => self.search.search.delete_next(), - }; - if changed { - self.schedule_search_update(cx); - } - cx.notify(); - } - fn sync_scroll_for_render(&mut self, cx: &mut Context) { // Keep the view pinned to the last line while we're following the live view. // This needs to happen with the latest `last_content()` (post-sync), so we do it here. @@ -2768,19 +1293,6 @@ impl TerminalView { } } -#[cfg(test)] -fn format_clock(d: Duration) -> String { - let secs = d.as_secs(); - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - if h > 0 { - format!("{h:02}:{m:02}:{s:02}") - } else { - format!("{m:02}:{s:02}") - } -} - fn no_command_block_detail(blocks: &[crate::command_blocks::CommandBlock], stable: i64) -> String { if blocks.is_empty() { "No OSC 133 blocks detected yet. Ensure this tab is a local bash or zsh shell with TERMUA \ @@ -2912,9 +1424,20 @@ fn render_scrollbar_preview_line_numbers( #[cfg(test)] mod scrollbar_preview_tests { - use std::rc::Rc; + use std::{borrow::Cow, ops::RangeInclusive, rc::Rc}; - use super::*; + use gpui::{ + AppContext, Bounds, Context as GpuiContext, Entity, InteractiveElement, Keystroke, + Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + ScrollWheelEvent, Styled, Window, div, point, px, size, + }; + use gpui_component::Root; + + use super::{TerminalView, format_scrollbar_preview_line_number}; + use crate::{ + Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy, + TerminalType, settings::CursorShape, terminal::TerminalBounds, + }; #[test] fn format_scrollbar_preview_line_number_right_aligns() { @@ -2929,19 +1452,6 @@ mod scrollbar_preview_tests { assert_eq!(format_scrollbar_preview_line_number(1, 1), "1\u{00A0}"); } - use std::{borrow::Cow, ops::RangeInclusive}; - - use gpui::{ - AppContext, Bounds, Context as GpuiContext, Keystroke, Modifiers, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, Pixels, ScrollWheelEvent, Window, point, px, size, - }; - use gpui_component::Root; - - use crate::{ - Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy, - TerminalType, - }; - pub(super) struct PreviewBackend { content: TerminalContent, matches: Vec>, @@ -3207,9 +1717,12 @@ mod scrollbar_preview_tests { mod suggestion_selection_tests { use std::rc::Rc; - use gpui::AppContext; + use gpui::{AppContext, Entity}; - use super::{scrollbar_preview_tests::PreviewBackend, *}; + use super::{ + SuggestionItem, SuggestionsState, TerminalView, scrollbar_preview_tests::PreviewBackend, + }; + use crate::{GridPoint, TerminalContent}; #[gpui::test] fn suggestions_open_has_no_default_selection(cx: &mut gpui::TestAppContext) { @@ -3429,15 +1942,22 @@ mod suggestion_acceptance_shell_agnostic_tests { use std::{ borrow::Cow, cell::RefCell, + ops::RangeInclusive, rc::Rc, sync::{Arc, Mutex}, }; - use gpui::{AppContext, Bounds, Modifiers, MouseMoveEvent, MouseUpEvent, Pixels}; + use gpui::{ + AppContext, Bounds, Context, Entity, Keystroke, Modifiers, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, ScrollWheelEvent, Window, + }; use gpui_component::Root; - use super::*; - use crate::TerminalBackend; + use super::{SuggestionItem, TerminalView}; + use crate::{ + GridPoint, TerminalBackend, TerminalBounds, TerminalContent, TerminalSettings, + settings::CursorShape, + }; #[derive(Clone, Debug, Eq, PartialEq)] enum BackendEvent { @@ -3787,14 +2307,21 @@ mod prompt_context_tests { mod snippet_placeholder_key_down_tests { use std::{ borrow::Cow, + ops::RangeInclusive, sync::{Arc, Mutex}, }; - use gpui::{AppContext, Bounds, Modifiers, MouseMoveEvent, MouseUpEvent, Pixels}; + use gpui::{ + AppContext, Bounds, Context, Entity, Keystroke, Modifiers, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, ScrollWheelEvent, Window, + }; use gpui_component::Root; - use super::*; - use crate::TerminalBackend; + use super::TerminalView; + use crate::{ + GridPoint, TerminalBackend, TerminalBounds, TerminalContent, TerminalSettings, + settings::CursorShape, snippet::SnippetSession, + }; #[derive(Clone, Debug, Eq, PartialEq)] enum BackendEvent { @@ -4038,9 +2565,23 @@ mod snippet_placeholder_key_down_tests { #[cfg(test)] mod tests { - use super::{format_clock, no_command_block_detail}; + use std::time::Duration; + + use super::no_command_block_detail; use crate::command_blocks::CommandBlock; + fn format_clock(d: Duration) -> String { + let secs = d.as_secs(); + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{h:02}:{m:02}:{s:02}") + } else { + format!("{m:02}:{s:02}") + } + } + #[test] fn format_clock_displays_mm_ss_or_hh_mm_ss() { assert_eq!(format_clock(std::time::Duration::from_secs(0)), "00:00"); diff --git a/crates/gpui_term/src/view/render.rs b/crates/gpui_term/src/view/render.rs new file mode 100644 index 0000000..958d15f --- /dev/null +++ b/crates/gpui_term/src/view/render.rs @@ -0,0 +1,190 @@ +use gpui::{ + Context, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + ParentElement, Render, Styled, Window, div, +}; +use gpui_component::menu::ContextMenu; + +use super::TerminalView; +use crate::{element::TerminalElement, terminal::Terminal}; + +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let terminal_handle = self.terminal.clone(); + let terminal_view_handle = cx.entity(); + + self.sync_scroll_for_render(cx); + let focused = self.focus_handle.is_focused(window); + + let mut root = self.terminal_view_root_base(cx); + root = self.terminal_view_root_mouse_handlers(root, cx); + root = root.child(self.terminal_view_inner_wrapper( + terminal_handle, + terminal_view_handle.clone(), + focused, + cx, + )); + root = root.children(self.collect_overlay_elements(window, cx)); + + if !self.context_menu_enabled { + return root.into_any_element(); + } + + let context_menu_enabled = self.context_menu_enabled; + let action_context = self.focus_handle.clone(); + let menu_terminal_handle = self.terminal.clone(); + let terminal_view = terminal_view_handle.clone(); + let context_menu_provider = self.context_menu_provider.clone(); + + ContextMenu::new("terminal-view-context-menu", root) + .menu(move |menu, window, cx| { + Self::build_terminal_context_menu( + context_menu_enabled, + action_context.clone(), + menu_terminal_handle.clone(), + terminal_view.clone(), + context_menu_provider.clone(), + menu, + window, + cx, + ) + }) + .into_any_element() + } +} + +impl TerminalView { + fn terminal_view_root_base(&mut self, cx: &mut Context) -> gpui::Stateful { + div() + .id("terminal-view") + .size_full() + .relative() + .track_focus(&self.focus_handle(cx)) + .key_context(self.dispatch_context(cx)) + .on_action(cx.listener(TerminalView::send_text)) + .on_action(cx.listener(TerminalView::send_keystroke)) + .on_action(cx.listener(TerminalView::open_search)) + .on_action(cx.listener(TerminalView::search_next)) + .on_action(cx.listener(TerminalView::search_previous)) + .on_action(cx.listener(TerminalView::close_search)) + .on_action(cx.listener(TerminalView::search_paste)) + .on_action(cx.listener(TerminalView::copy)) + .on_action(cx.listener(TerminalView::paste)) + .on_action(cx.listener(TerminalView::clear)) + .on_action(cx.listener(TerminalView::reset_font_size)) + .on_action(cx.listener(TerminalView::increase_font_size)) + .on_action(cx.listener(TerminalView::decrease_font_size)) + .on_action(cx.listener(TerminalView::scroll_line_up)) + .on_action(cx.listener(TerminalView::scroll_line_down)) + .on_action(cx.listener(TerminalView::scroll_page_up)) + .on_action(cx.listener(TerminalView::scroll_page_down)) + .on_action(cx.listener(TerminalView::scroll_to_top)) + .on_action(cx.listener(TerminalView::scroll_to_bottom)) + .on_action(cx.listener(TerminalView::toggle_vi_mode)) + .on_action(cx.listener(TerminalView::show_character_palette)) + .on_action(cx.listener(TerminalView::select_all)) + .on_action(cx.listener(TerminalView::start_cast_recording)) + .on_action(cx.listener(TerminalView::stop_cast_recording)) + .on_action(cx.listener(TerminalView::toggle_cast_recording)) + .on_key_down(cx.listener(Self::key_down)) + } + + fn terminal_view_root_mouse_handlers( + &mut self, + root: gpui::Stateful, + cx: &mut Context, + ) -> gpui::Stateful { + let root = root.on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + // Treat the left gutter (line numbers/padding) as UI chrome: allow selecting + // command blocks there rather than starting a terminal selection. + let content_bounds = { + let terminal = this.terminal.read(cx); + terminal.last_content().terminal_bounds.bounds + }; + if event.position.x < content_bounds.origin.x { + this.close_suggestions(cx); + this.select_command_block_at_y( + event.position.y, + event.modifiers.shift, + window, + cx, + ); + cx.stop_propagation(); + } + }), + ); + + if !self.context_menu_enabled { + return root; + } + + root.on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.close_suggestions(cx); + + // We treat the left gutter (outside the terminal content bounds) as "UI + // chrome", not part of the terminal application. Allow the + // context menu there even when the terminal is in mouse + // mode (e.g. vim/tmux). + let (content_bounds, mouse_mode_enabled, has_selection) = { + let terminal = this.terminal.read(cx); + ( + terminal.last_content().terminal_bounds.bounds, + terminal.mouse_mode(event.modifiers.shift), + terminal.last_content().selection.is_some(), + ) + }; + let clicked_in_gutter = event.position.x < content_bounds.origin.x; + if clicked_in_gutter { + // Pre-select the block (if any) so context-menu actions apply. + this.select_command_block_at_y( + event.position.y, + event.modifiers.shift, + window, + cx, + ); + return; + } + + // When the terminal is in mouse mode (e.g. vim/tmux), don't open the context + // menu; let the application handle right clicks. + if mouse_mode_enabled { + cx.stop_propagation(); + return; + } + + if !has_selection { + this.terminal.update(cx, |terminal, _| { + terminal.select_word_at_event_position(event); + }); + window.refresh(); + } + }), + ) + } + + fn terminal_view_inner_wrapper( + &mut self, + terminal_handle: Entity, + terminal_view_handle: Entity, + focused: bool, + cx: &mut Context, + ) -> gpui::Stateful { + // NOTE: Keep a wrapper div around `TerminalElement`; without it the terminal + // element can interfere with overlay UI (context menu, etc). + div() + .id("terminal-view-inner") + .size_full() + .relative() + .child(TerminalElement::new( + terminal_handle, + terminal_view_handle, + self.focus_handle.clone(), + focused, + self.should_show_cursor(focused, cx), + self.scroll.block_below_cursor.clone(), + )) + } +} diff --git a/crates/gpui_term/src/view/scrollbar.rs b/crates/gpui_term/src/view/scrolling.rs similarity index 50% rename from crates/gpui_term/src/view/scrollbar.rs rename to crates/gpui_term/src/view/scrolling.rs index cb405e5..fe3e9b8 100644 --- a/crates/gpui_term/src/view/scrollbar.rs +++ b/crates/gpui_term/src/view/scrolling.rs @@ -1,10 +1,21 @@ -use std::{ops::RangeInclusive, rc::Rc}; +use std::{cmp, ops::RangeInclusive, rc::Rc, time::Duration}; -use gpui::{BorderStyle, Bounds, Pixels, Point, Window, fill, outline, point, px, size}; +use gpui::{ + App, BorderStyle, Bounds, Context, Pixels, Point, ReadGlobal, ScrollWheelEvent, Window, fill, + outline, point, px, size, +}; use gpui_component::ActiveTheme; - -use super::BlockProperties; -use crate::GridPoint; +use smol::Timer; + +use super::{BlockProperties, TerminalScrollState, TerminalView}; +use crate::{ + GridPoint, point_to_viewport, + settings::TerminalSettings, + terminal::{ + ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, + ToggleViMode, + }, +}; pub(crate) const SCROLLBAR_WIDTH: Pixels = px(14.0); pub(crate) const SCROLLBAR_PAD: Pixels = px(2.0); @@ -495,9 +506,544 @@ pub(crate) fn paint_overlay_scrollbar( } } +impl TerminalView { + pub(super) fn max_scroll_top(&self, cx: &App) -> Pixels { + let terminal = self.terminal.read(cx); + + let Some(block) = self.scroll.block_below_cursor.as_ref() else { + return Pixels::ZERO; + }; + + let content = terminal.last_content(); + let line_height = content.terminal_bounds.line_height; + let viewport_lines = terminal.viewport_lines(); + let cursor = + point_to_viewport(content.display_offset, content.cursor.point).unwrap_or_default(); + let max_scroll_top_in_lines = + (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1)); + + max_scroll_top_in_lines as f32 * line_height + } + + /// Zed-like behavior: if the user is looking at history (terminal scrollback) or has scrolled + /// away from the live block content, any input that is forwarded to the PTY should snap the + /// view back to the bottom. + pub(super) fn snap_to_bottom_on_input(&mut self, cx: &mut Context) { + let TerminalScrollState { + display_offset, + line_height, + .. + } = self.terminal_scroll_state(cx); + + // History scrolling: ensure we immediately return to the live viewport on any PTY input. + // (Some input paths, like `try_keystroke`, don't go through backend `input()`.) + if display_offset != 0 { + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = true; + cx.notify(); + return; + } + + // Block-below-cursor extra scroll space (e.g. prompt block). Only applies in live view. + if self.scroll.block_below_cursor.is_some() { + let max = self.max_scroll_top(cx); + let at_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; + + if !at_bottom { + self.scroll.scroll_top = max; + } + } + + // Typing implies "follow output" unless the user scrolls away again. + self.scroll.stick_to_bottom = true; + cx.notify(); + } + + pub(crate) fn set_mouse_left_down_in_terminal(&mut self, down: bool) { + self.scroll.mouse_left_down_in_terminal = down; + } + + pub(crate) fn mouse_left_down_in_terminal(&self) -> bool { + self.scroll.mouse_left_down_in_terminal + } + + pub(crate) fn scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + // Scroll-wheel usage should reveal the overlay scrollbar briefly, even if the pointer + // isn't within the scrollbar lane. + // + // Note: This is kept view-local so we can avoid allocating dedicated layout space. + // The element decides whether to paint the scrollbar based on this flag. + // (Timer-based auto-hide happens in `reveal_scrollbar_for_scroll`.) + // + // We don't require focus checks here; callers already gate on focus. + self.reveal_scrollbar_for_scroll(window, cx); + + let TerminalScrollState { + is_remote_mirror, + display_offset, + line_height, + } = self.terminal_scroll_state(cx); + + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_wheel(event)); + return; + } + + if self.scroll.block_below_cursor.is_some() && display_offset == 0 { + let y_delta = event.delta.pixel_delta(line_height).y; + if y_delta < Pixels::ZERO || self.scroll.scroll_top > Pixels::ZERO { + let max = self.max_scroll_top(cx); + self.scroll.scroll_top = cmp::max( + Pixels::ZERO, + cmp::min(self.scroll.scroll_top - y_delta, max), + ); + self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; + cx.notify(); + return; + } + } + // Scrolling the terminal history should never keep a block-scroll offset. + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + self.terminal.update(cx, |term, _| term.scroll_wheel(event)); + } + + pub(crate) fn scrollbar_dragging(&self) -> bool { + self.scroll.scrollbar_dragging + } + + pub(crate) fn scrollbar_hovered(&self) -> bool { + self.scroll.scrollbar_hovered + } + + pub(crate) fn scrollbar_revealed(&self) -> bool { + self.scroll.scrollbar_revealed + } + + pub(crate) fn set_scrollbar_hovered(&mut self, hovered: bool, cx: &mut Context) { + if self.scroll.scrollbar_hovered != hovered { + self.scroll.scrollbar_hovered = hovered; + cx.notify(); + } + } + + fn reveal_scrollbar_for_scroll(&mut self, _window: &mut Window, cx: &mut Context) { + if !TerminalSettings::global(cx).show_scrollbar { + return; + } + self.scroll.scrollbar_revealed = true; + self.scroll.scrollbar_reveal_epoch = self.scroll.scrollbar_reveal_epoch.wrapping_add(1); + let epoch = self.scroll.scrollbar_reveal_epoch; + cx.notify(); + + // Auto-hide after a short delay. If the user is hovering/dragging at that time, the + // scrollbar stays visible due to those signals (not this temporary reveal flag). + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_millis(900)).await; + let _ = this.update(cx, |this, cx| { + if this.scroll.scrollbar_reveal_epoch != epoch { + return; + } + if this.scroll.scrollbar_revealed { + this.scroll.scrollbar_revealed = false; + cx.notify(); + } + }); + }) + .detach(); + } + + pub(crate) fn scroll_top(&self) -> Pixels { + self.scroll.scroll_top + } + + pub(crate) fn scrollbar_virtual_offset(&self) -> Option { + self.scroll.scrollbar_virtual_offset + } + + pub(crate) fn scrollbar_drag_origin(&self) -> Option<(Pixels, usize)> { + Some(( + self.scroll.scrollbar_drag_start_y?, + self.scroll.scrollbar_drag_start_offset?, + )) + } + + pub(crate) fn set_scrollbar_drag_origin(&mut self, mouse_y: Pixels, offset: usize) { + self.scroll.scrollbar_drag_start_y = Some(mouse_y); + self.scroll.scrollbar_drag_start_offset = Some(offset); + } + + pub(crate) fn begin_scrollbar_drag(&mut self, mouse_y: Pixels, cx: &mut Context) { + self.scroll.scrollbar_dragging = true; + let current = { + let terminal = self.terminal.read(cx); + terminal.last_content().display_offset + }; + self.scroll.scrollbar_virtual_offset = Some(current); + self.scroll.scrollbar_last_target_offset = Some(current); + self.set_scrollbar_drag_origin(mouse_y, current); + } + + pub(crate) fn end_scrollbar_drag(&mut self) { + self.scroll.scrollbar_dragging = false; + self.scroll.scrollbar_last_target_offset = None; + self.scroll.scrollbar_virtual_offset = None; + self.scroll.scrollbar_drag_start_y = None; + self.scroll.scrollbar_drag_start_offset = None; + } + + pub(crate) fn scrollbar_preview(&self) -> Option<&ScrollbarPreview> { + self.scroll.scrollbar_preview.as_ref() + } + + pub(crate) fn clear_scrollbar_preview(&mut self, cx: &mut Context) { + if self.scroll.scrollbar_preview.take().is_some() { + cx.notify(); + } + } + + pub(crate) fn set_scrollbar_preview_for_match( + &mut self, + match_index: usize, + anchor: gpui::Point, + cx: &mut Context, + ) { + let Some((start, cols, rows, cells, match_range)) = (|| { + let terminal = self.terminal.read(cx); + let matches = terminal.matches(); + if match_index >= matches.len() { + return None; + } + + let total_lines = terminal.total_lines(); + let viewport_lines = terminal.viewport_lines(); + let match_range = matches[match_index].clone(); + + let start_line_coord = match_range.start().line; + let end_line_coord = match_range.end().line; + let start_line_from_top = + buffer_index_for_line_coord(total_lines, viewport_lines, start_line_coord); + let end_line_from_top = + buffer_index_for_line_coord(total_lines, viewport_lines, end_line_coord); + + let context_above = 3usize; + let total = 7usize; + let start = start_line_from_top.saturating_sub(context_above); + + let (cols, rows, cells) = terminal.preview_cells_from_top(start, total); + if rows == 0 || cells.is_empty() { + return None; + } + + // Convert the match range into preview-local coordinates (preview starts at line 0). + let local_start_line = start_line_from_top.saturating_sub(start); + let local_end_line = end_line_from_top.saturating_sub(start); + let local_range = RangeInclusive::new( + GridPoint::new(local_start_line as i32, match_range.start().column), + GridPoint::new(local_end_line as i32, match_range.end().column), + ); + + Some((start, cols, rows, cells, local_range)) + })() else { + self.clear_scrollbar_preview(cx); + return; + }; + + // Avoid re-fetching preview text while the pointer moves within the same marker. + if let Some(prev) = self.scroll.scrollbar_preview.as_mut() + && prev.match_index == match_index + && prev.start_line_from_top == start + { + prev.anchor = anchor; + cx.notify(); + return; + } + + self.scroll.scrollbar_preview = Some(ScrollbarPreview { + match_index, + anchor, + start_line_from_top: start, + cols, + rows, + cells, + match_range, + }); + cx.notify(); + } + + pub(crate) fn apply_scrollbar_target_offset( + &mut self, + target_offset: usize, + cx: &mut Context, + ) { + if self.scroll.scrollbar_last_target_offset == Some(target_offset) { + return; + } + self.scroll.scrollbar_last_target_offset = Some(target_offset); + let current = self.scroll.scrollbar_virtual_offset.unwrap_or_else(|| { + let terminal = self.terminal.read(cx); + terminal.last_content().display_offset + }); + self.scroll_to_display_offset_from_current(current, target_offset, cx); + self.scroll.scrollbar_virtual_offset = Some(target_offset); + } + + fn scroll_to_display_offset_from_current( + &mut self, + current_offset: usize, + target_offset: usize, + cx: &mut Context, + ) { + let (is_remote_mirror, max_offset) = { + let terminal = self.terminal.read(cx); + let total_lines = terminal.total_lines(); + let viewport_lines = terminal.viewport_lines(); + ( + terminal.is_remote_mirror(), + total_lines.saturating_sub(viewport_lines), + ) + }; + + let current_offset = current_offset.min(max_offset); + let target_offset = target_offset.min(max_offset); + if target_offset == current_offset { + if !is_remote_mirror { + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = target_offset == 0; + } + return; + } + + if !is_remote_mirror { + // Scrolling the terminal history should never keep the extra "block below cursor" + // scroll. + self.scroll.scroll_top = Pixels::ZERO; + } + + self.terminal.update(cx, |term, _| { + if target_offset == 0 { + term.scroll_to_bottom(); + } else if target_offset == max_offset { + term.scroll_to_top(); + } else if target_offset > current_offset { + term.scroll_up_by(target_offset - current_offset); + } else { + term.scroll_down_by(current_offset - target_offset); + } + }); + + if !is_remote_mirror { + self.scroll.stick_to_bottom = target_offset == 0; + cx.notify(); + } + } + + // `scroll_to_display_offset_from_current` is the only implementation we need right now. + + pub(super) fn scroll_line_up( + &mut self, + _: &ScrollLineUp, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let TerminalScrollState { + is_remote_mirror, + display_offset, + line_height, + } = self.terminal_scroll_state(cx); + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_line_up()); + return; + } + if self.scroll.block_below_cursor.is_some() + && display_offset == 0 + && self.scroll.scroll_top > Pixels::ZERO + { + self.scroll.scroll_top = cmp::max(self.scroll.scroll_top - line_height, Pixels::ZERO); + let max = self.max_scroll_top(cx); + self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max; + return; + } + + self.terminal.update(cx, |term, _| term.scroll_line_up()); + // Terminal scrollback and block scrolling are mutually exclusive. + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + cx.notify(); + } + + pub(super) fn scroll_line_down( + &mut self, + _: &ScrollLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let TerminalScrollState { + is_remote_mirror, + display_offset, + line_height, + } = self.terminal_scroll_state(cx); + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_line_down()); + return; + } + if self.scroll.block_below_cursor.is_some() && display_offset == 0 { + let max_scroll_top = self.max_scroll_top(cx); + if self.scroll.scroll_top < max_scroll_top { + self.scroll.scroll_top = + cmp::min(self.scroll.scroll_top + line_height, max_scroll_top); + } + self.scroll.stick_to_bottom = + self.scroll.scroll_top + line_height / 2.0 >= max_scroll_top; + return; + } + + self.terminal.update(cx, |term, _| term.scroll_line_down()); + // Terminal scrollback and block scrolling are mutually exclusive. + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + cx.notify(); + } + + pub(super) fn scroll_page_up( + &mut self, + _: &ScrollPageUp, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let (is_remote_mirror, line_height, viewport_lines) = { + let terminal = self.terminal.read(cx); + ( + terminal.is_remote_mirror(), + terminal.last_content().terminal_bounds.line_height(), + terminal.viewport_lines(), + ) + }; + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_page_up()); + return; + } + if self.scroll.scroll_top == Pixels::ZERO { + self.terminal.update(cx, |term, _| term.scroll_page_up()); + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + } else { + let visible_block_lines = (self.scroll.scroll_top / line_height) as usize; + let visible_content_lines = viewport_lines - visible_block_lines; + + if visible_block_lines >= viewport_lines { + self.scroll.scroll_top = + ((visible_block_lines - viewport_lines) as f32) * line_height; + } else { + self.scroll.scroll_top = px(0.); + self.terminal + .update(cx, |term, _| term.scroll_up_by(visible_content_lines)); + } + self.scroll.stick_to_bottom = + self.scroll.scroll_top + line_height / 2.0 >= self.max_scroll_top(cx); + } + cx.notify(); + } + + pub(super) fn scroll_page_down( + &mut self, + _: &ScrollPageDown, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let TerminalScrollState { + is_remote_mirror, + display_offset, + .. + } = self.terminal_scroll_state(cx); + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_page_down()); + return; + } + self.terminal.update(cx, |term, _| term.scroll_page_down()); + // Scrolling the terminal history should not apply block scrolling offsets. + // `scroll_top` is only meaningful while we're at the live view. + if self.scroll.block_below_cursor.is_some() && display_offset == 0 { + self.scroll.scroll_top = self.max_scroll_top(cx); + self.scroll.stick_to_bottom = true; + } else { + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + } + cx.notify(); + } + + pub(super) fn scroll_to_top( + &mut self, + _: &ScrollToTop, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror; + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_to_top()); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_top()); + self.scroll.scroll_top = Pixels::ZERO; + self.scroll.stick_to_bottom = false; + cx.notify(); + } + + pub(super) fn scroll_to_bottom( + &mut self, + _: &ScrollToBottom, + window: &mut Window, + cx: &mut Context, + ) { + self.reveal_scrollbar_for_scroll(window, cx); + let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror; + if is_remote_mirror { + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); + if self.scroll.block_below_cursor.is_some() { + self.scroll.scroll_top = self.max_scroll_top(cx); + } else { + self.scroll.scroll_top = Pixels::ZERO; + } + self.scroll.stick_to_bottom = true; + cx.notify(); + } + + pub(super) fn toggle_vi_mode( + &mut self, + _: &ToggleViMode, + _: &mut Window, + cx: &mut Context, + ) { + self.terminal.update(cx, |term, _| term.toggle_vi_mode()); + cx.notify(); + } +} + #[cfg(test)] mod tests { - use super::*; + use gpui::{Bounds, point, px, size}; + + use super::{ + buffer_index_for_line_coord, scroll_offset_for_line_coord_centered, + scrollbar_marker_y_for_line_coord, search_match_index_for_scrollbar_click, + search_match_index_for_scrollbar_hover, + }; + use crate::GridPoint; #[test] fn scrollbar_marker_y_maps_entire_buffer_top_to_bottom() { diff --git a/crates/gpui_term/src/view/search.rs b/crates/gpui_term/src/view/search.rs index dca04b9..c72fbb2 100644 --- a/crates/gpui_term/src/view/search.rs +++ b/crates/gpui_term/src/view/search.rs @@ -1,21 +1,27 @@ +use std::{ops::Range, time::Duration}; + use gpui::{ - AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ReadGlobal, Styled, Window, div, point, - px, + AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ReadGlobal, Styled, + Window, div, point, px, }; use gpui_component::ActiveTheme; +use smol::Timer; use unicode_segmentation::UnicodeSegmentation; use super::{ - ImeState, TerminalView, - scrollbar::{ + ImeState, SearchOverlayDelete, SearchOverlayKeyDown, SearchOverlayMove, TerminalView, + scrolling::{ SCROLLBAR_WIDTH, scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y, scrollbar_bounds_for_terminal, scrollbar_track_bounds, search_match_index_for_scrollbar_click, search_match_index_for_scrollbar_hover, thumb_bounds_for_track, }, }; -use crate::{settings::TerminalSettings, terminal::SearchClose}; +use crate::{ + settings::TerminalSettings, + terminal::{Search, SearchClose, SearchNext, SearchPaste, SearchPrevious}, +}; fn search_match_counter(view: &TerminalView, cx: &mut Context) -> String { let terminal = view.terminal.read(cx); @@ -662,6 +668,329 @@ pub(crate) fn render_search( ) } +impl TerminalView { + pub(super) fn open_search(&mut self, _: &Search, window: &mut Window, cx: &mut Context) { + self.search.search_open = true; + self.search.search_panel_dragging = false; + self.search.search_panel_drag_start_mouse = None; + self.search.search_panel_drag_start_pos = None; + self.search.search_expected_commit = None; + self.search.search.end(); + + // Default position: near the top, centered within the current window. + let viewport = window.viewport_size(); + let panel_w = px(520.0) + .min((viewport.width - px(24.0)).max(Pixels::ZERO)) + .max(px(320.0).min(viewport.width.max(Pixels::ZERO))); + let keep = px(32.0); + if !self.search.search_panel_pos_initialized { + let x = (viewport.width - panel_w).max(Pixels::ZERO) / 2.0; + self.search.search_panel_pos = gpui::point(x, px(72.0)); + self.search.search_panel_pos_initialized = true; + } else { + // If the window size changed since the last open, keep the panel reachable. + let mut pos = self.search.search_panel_pos; + pos.x = pos + .x + .clamp((Pixels::ZERO - panel_w) + keep, viewport.width - keep); + pos.y = pos.y.clamp(px(0.0), viewport.height - keep); + self.search.search_panel_pos = pos; + } + + window.focus(&self.focus_handle, cx); + cx.notify(); + } + + pub(super) fn close_search( + &mut self, + _: &SearchClose, + window: &mut Window, + cx: &mut Context, + ) { + self.search.search_open = false; + self.clear_scrollbar_preview(cx); + self.search.search_epoch = self.search.search_epoch.wrapping_add(1); + self.search.search.clear(); + self.search.search_ime_state = None; + self.search.search_expected_commit = None; + self.search.search_panel_dragging = false; + self.search.search_panel_drag_start_mouse = None; + self.search.search_panel_drag_start_pos = None; + + window.focus(&self.focus_handle, cx); + + self.terminal.update(cx, |term, _| { + term.set_search_query(None); + term.select_matches(&[]); + }); + cx.notify(); + } + + pub(super) fn search_next( + &mut self, + _: &SearchNext, + _window: &mut Window, + cx: &mut Context, + ) { + self.jump_search(true, cx); + } + + pub(super) fn search_previous( + &mut self, + _: &SearchPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + self.jump_search(false, cx); + } + + pub(super) fn jump_search(&mut self, forward: bool, cx: &mut Context) { + self.terminal.update(cx, |term, _| { + let matches_len = term.matches().len(); + if matches_len == 0 { + return; + } + + let cur = term.active_match_index().unwrap_or(0) % matches_len; + let next = if forward { + (cur + 1) % matches_len + } else { + cur.checked_sub(1).unwrap_or(matches_len - 1) + }; + term.activate_match(next); + term.jump_to_match(next); + }); + cx.notify(); + } + + pub(super) fn search_paste(&mut self, _: &SearchPaste, _: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + self.search.search.insert(&text); + self.schedule_search_update(cx); + } + } + + pub(crate) fn commit_search_text(&mut self, text: &str, cx: &mut Context) { + if text.is_empty() { + return; + } + if self.search.search_expected_commit.as_deref() == Some(text) { + self.search.search_expected_commit = None; + return; + } + self.search.search_expected_commit = None; + self.search.search_ime_state = None; + self.search.search.insert(text); + self.schedule_search_update(cx); + } + + pub(crate) fn set_search_marked_text( + &mut self, + text: String, + range: Option>, + cx: &mut Context, + ) { + self.search.search_ime_state = Some(ImeState { + marked_text: text, + marked_range_utf16: range, + }); + cx.notify(); + } + + pub(crate) fn clear_search_marked_text(&mut self, cx: &mut Context) { + self.search.search_ime_state = None; + cx.notify(); + } + + pub(crate) fn is_search_open(&self) -> bool { + self.search.search_open + } + + pub(crate) fn search_marked_text_range(&self) -> Option> { + self.search + .search_ime_state + .as_ref() + .and_then(|state| state.marked_range_utf16.clone()) + } + + pub(crate) fn search_panel_pos(&self) -> gpui::Point { + self.search.search_panel_pos + } + + fn schedule_search_update(&mut self, cx: &mut Context) { + let epoch = self.search.search_epoch.wrapping_add(1); + self.search.search_epoch = epoch; + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_millis(150)).await; + let _ = this.update(cx, |this, cx| { + if !this.search.search_open || this.search.search_epoch != epoch { + return; + } + + // Only treat all-whitespace as empty; otherwise keep the query exactly as typed. + let q = this.search.search.text().to_string(); + this.terminal.update(cx, |term, _| { + if q.chars().all(|c| c.is_whitespace()) { + term.set_search_query(None); + } else { + term.set_search_query(Some(q)); + if !term.matches().is_empty() { + term.activate_match(0); + } + } + }); + cx.notify(); + }); + }) + .detach(); + } + + pub(crate) fn search_cursor_utf16(&self) -> usize { + self.search.search.cursor_utf16() + } + + pub(super) fn handle_search_overlay_key_down_for_terminal_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match self.handle_search_overlay_key_down(event, window, cx) { + SearchOverlayKeyDown::NotOpen => false, + SearchOverlayKeyDown::Return => true, + SearchOverlayKeyDown::StopAndReturn => { + // Search is a overlay; don't forward keystrokes to the terminal. + cx.stop_propagation(); + true + } + } + } + + fn handle_search_overlay_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> SearchOverlayKeyDown { + if !self.search.search_open { + return SearchOverlayKeyDown::NotOpen; + } + + match event.keystroke.key.as_str() { + "escape" => { + self.close_search(&SearchClose, window, cx); + } + "enter" => { + self.jump_search(!event.keystroke.modifiers.shift, cx); + } + "left" => self.search_overlay_move_cursor(SearchOverlayMove::Left, event, cx), + "right" => self.search_overlay_move_cursor(SearchOverlayMove::Right, event, cx), + "home" => self.search_overlay_move_cursor(SearchOverlayMove::Home, event, cx), + "end" => self.search_overlay_move_cursor(SearchOverlayMove::End, event, cx), + "backspace" => self.search_overlay_delete(SearchOverlayDelete::Prev, event, cx), + "delete" => self.search_overlay_delete(SearchOverlayDelete::Next, event, cx), + // Common terminal muscle memory: cmd/ctrl+a moves to start of the input. + "a" if event.keystroke.modifiers.secondary() => { + self.search.search.home(); + cx.notify(); + } + // Support cmd/ctrl+v paste into the query even if keybinding dispatch is skipped. + "v" if event.keystroke.modifiers.secondary() => { + self.search_paste(&SearchPaste, window, cx); + } + // Support cmd/ctrl+g next/prev even if keybinding dispatch is skipped. + "g" if event.keystroke.modifiers.secondary() => { + self.jump_search(!event.keystroke.modifiers.shift, cx); + } + _ => { + // Text input (including IME) is handled via the InputHandler installed by the + // terminal element while the search is open. + // + // However, on some platforms plain latin text does *not* go through the IME + // callbacks; in that case we fall back to `key_char`. + if self.search_overlay_is_composing(event) { + // IME is actively composing; don't insert raw keystrokes. + return SearchOverlayKeyDown::Return; + } + + if event.keystroke.modifiers.control + || event.keystroke.modifiers.platform + || event.keystroke.modifiers.function + || event.keystroke.modifiers.alt + { + return SearchOverlayKeyDown::Return; + } + + if let Some(ch) = event.keystroke.key_char.as_ref() + && !ch.is_empty() + { + self.search.search_expected_commit = Some(ch.clone()); + self.search.search.insert(ch); + self.schedule_search_update(cx); + } + } + } + + SearchOverlayKeyDown::StopAndReturn + } + + fn search_overlay_is_composing(&self, event: &KeyDownEvent) -> bool { + self.search_overlay_has_marked_text() || event.keystroke.is_ime_in_progress() + } + + fn search_overlay_has_marked_text(&self) -> bool { + self.search + .search_ime_state + .as_ref() + .is_some_and(|ime| !ime.marked_text.is_empty()) + } + + fn search_overlay_move_cursor( + &mut self, + movement: SearchOverlayMove, + event: &KeyDownEvent, + cx: &mut Context, + ) { + if self.search_overlay_is_composing(event) { + // Let the IME handle caret movement inside an active composition. + return; + } + + match movement { + SearchOverlayMove::Left => self.search.search.move_left(), + SearchOverlayMove::Right => self.search.search.move_right(), + SearchOverlayMove::Home => self.search.search.home(), + SearchOverlayMove::End => self.search.search.end(), + } + cx.notify(); + } + + fn search_overlay_delete( + &mut self, + delete: SearchOverlayDelete, + event: &KeyDownEvent, + cx: &mut Context, + ) { + let should_ignore = match delete { + SearchOverlayDelete::Prev => self.search_overlay_has_marked_text(), + SearchOverlayDelete::Next => self.search_overlay_is_composing(event), + }; + if should_ignore { + // Let the platform IME drive composition edits via the InputHandler. + return; + } + + let changed = match delete { + SearchOverlayDelete::Prev => self.search.search.delete_prev(), + SearchOverlayDelete::Next => self.search.search.delete_next(), + }; + if changed { + self.schedule_search_update(cx); + } + cx.notify(); + } +} + #[cfg(test)] mod tests { use super::SearchBuffer; diff --git a/crates/gpui_term/src/view/suggestions.rs b/crates/gpui_term/src/view/suggestions.rs new file mode 100644 index 0000000..7110d60 --- /dev/null +++ b/crates/gpui_term/src/view/suggestions.rs @@ -0,0 +1,452 @@ +use std::time::Duration; + +use gpui::{App, Context, KeyDownEvent, Keystroke, ReadGlobal}; +use smol::Timer; + +use super::TerminalView; +use crate::{ + TerminalContent, TerminalMode, + settings::TerminalSettings, + snippet::{SnippetSession, parse_snippet_suffix}, + suggestions::{ + SelectionMove, SuggestionItem, SuggestionStaticConfig, compute_insert_suffix_for_line, + extract_cursor_line_prefix, extract_cursor_line_suffix, line_is_suggestion_prefix, + move_selection_opt, + }, +}; + +impl TerminalView { + pub(crate) fn suggestions_snapshot(&self) -> Option<(Vec, Option)> { + self.suggestions.open.then(|| { + let highlighted = self + .suggestions + .hovered + .or(self.suggestions.selected) + .and_then(|highlighted| { + let last = self.suggestions.items.len().saturating_sub(1); + (!self.suggestions.items.is_empty()).then_some(highlighted.min(last)) + }); + (self.suggestions.items.clone(), highlighted) + }) + } + + pub(crate) fn close_suggestions(&mut self, cx: &mut Context) { + if !self.suggestions.open { + return; + } + self.suggestions.close(); + cx.notify(); + } + + pub(crate) fn set_suggestions_hovered( + &mut self, + hovered: Option, + cx: &mut Context, + ) { + if !self.suggestions.open { + if self.suggestions.hovered.take().is_some() { + cx.notify(); + } + return; + } + + let hovered = hovered.and_then(|idx| { + let last = self.suggestions.items.len().saturating_sub(1); + (!self.suggestions.items.is_empty()).then_some(idx.min(last)) + }); + + if self.suggestions.hovered != hovered { + self.suggestions.hovered = hovered; + cx.notify(); + } + } + + pub(super) fn handle_suggestions_key_down( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) -> bool { + // Suggestions are intentionally conservative: remote/SSH sessions have no shell + // integration, so we only show/accept append-only hints in shell-like contexts. + if !TerminalSettings::global(cx).suggestions_enabled || self.snippet.is_some() { + return false; + } + + let Some(prompt) = self.prompt_context(cx) else { + return false; + }; + if !self.suggestions_eligible_for_content(&prompt.content, cx) { + self.suggestions.prompt_prefix = None; + self.suggestions.close(); + return false; + } + + match event.keystroke.key.as_str() { + "escape" if self.suggestions.open => { + self.suggestions.close(); + cx.notify(); + cx.stop_propagation(); + true + } + "up" if self.suggestions.open => { + self.suggestions.selected = move_selection_opt( + self.suggestions.selected, + self.suggestions.items.len(), + SelectionMove::Up, + ); + cx.notify(); + cx.stop_propagation(); + true + } + "down" if self.suggestions.open => { + self.suggestions.selected = move_selection_opt( + self.suggestions.selected, + self.suggestions.items.len(), + SelectionMove::Down, + ); + cx.notify(); + cx.stop_propagation(); + true + } + "enter" => { + if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) { + cx.stop_propagation(); + return true; + } + + if let Some(prompt_prefix) = self.suggestions.prompt_prefix.take() { + let line_prefix = extract_cursor_line_prefix(&prompt.content); + let input = line_prefix + .strip_prefix(&prompt_prefix) + .unwrap_or("") + .trim() + .to_string(); + if !input.is_empty() { + self.queue_command_for_history(input, cx); + } + } + self.suggestions.close(); + false + } + "right" => { + if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) { + cx.stop_propagation(); + return true; + } + false + } + "backspace" => { + if self.suggestions.prompt_prefix.is_none() { + self.suggestions.prompt_prefix = + Some(extract_cursor_line_prefix(&prompt.content)); + } + self.schedule_suggestions_update(cx); + false + } + _ => { + let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| { + !ch.is_empty() + && !event.keystroke.is_ime_in_progress() + && !event.keystroke.modifiers.control + && !event.keystroke.modifiers.platform + && !event.keystroke.modifiers.function + && !event.keystroke.modifiers.alt + }); + + if is_plain_text { + if self.suggestions.prompt_prefix.is_none() { + self.suggestions.prompt_prefix = + Some(extract_cursor_line_prefix(&prompt.content)); + } + self.schedule_suggestions_update(cx); + } else if self.suggestions.open { + self.suggestions.close(); + } + false + } + } + } + + pub(super) fn suggestions_eligible_for_content( + &self, + content: &TerminalContent, + cx: &App, + ) -> bool { + TerminalSettings::global(cx).suggestions_enabled + && content.display_offset == 0 + && !content.mode.contains(TerminalMode::ALT_SCREEN) + && content.selection.is_none() + && self.scroll.block_below_cursor.is_none() + } + + fn schedule_suggestions_update(&mut self, cx: &mut Context) { + let epoch = self.suggestions.epoch.wrapping_add(1); + self.suggestions.epoch = epoch; + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_millis(200)).await; + let _ = this.update(cx, |this, cx| { + if this.suggestions.epoch != epoch { + return; + } + + let Some(prompt) = this.prompt_context(cx) else { + return; + }; + if !this.suggestions_eligible_for_content(&prompt.content, cx) { + this.suggestions.prompt_prefix = None; + this.suggestions.close(); + cx.notify(); + return; + } + + let Some(prompt_prefix) = this.suggestions.prompt_prefix.clone() else { + this.suggestions.close(); + cx.notify(); + return; + }; + + let line_prefix = extract_cursor_line_prefix(&prompt.content); + let input_prefix = line_prefix.strip_prefix(&prompt_prefix).unwrap_or(""); + + this.suggestions.engine.max_items = + TerminalSettings::global(cx).suggestions_max_items; + + if let Some(cfg) = cx.try_global::() + && cfg.epoch != this.suggestions.static_epoch_seen + { + this.suggestions.static_epoch_seen = cfg.epoch; + this.suggestions + .engine + .set_static_provider(cfg.provider.clone()); + } + + let items = this.suggestions.engine.suggest(input_prefix); + this.suggestions.open_with_items(items); + cx.notify(); + }); + }) + .detach(); + } + + pub(super) fn accept_selected_suggestion( + &mut self, + content: &TerminalContent, + cursor_line_id: Option, + cx: &mut Context, + ) -> bool { + if !self.suggestions.open { + return false; + } + + let Some(selected) = self.suggestions.selected else { + return false; + }; + self.accept_suggestion_at_index(selected, content, cursor_line_id, cx) + } + + pub(crate) fn accept_suggestion_at_index( + &mut self, + index: usize, + content: &TerminalContent, + cursor_line_id: Option, + cx: &mut Context, + ) -> bool { + if !self.suggestions.open { + return false; + } + + let Some(item) = self.suggestions.items.get(index).cloned() else { + return false; + }; + + let line_prefix = extract_cursor_line_prefix(content); + let Some((input_prefix, suffix_template)) = compute_insert_suffix_for_line( + &line_prefix, + self.suggestions.prompt_prefix.as_deref(), + &item.full_text, + ) else { + return false; + }; + + let line_suffix = extract_cursor_line_suffix(content); + if !line_suffix.trim().is_empty() { + let combined_line = format!("{input_prefix}{line_suffix}"); + if line_is_suggestion_prefix(&combined_line, &item.full_text) { + return false; + } + } + + let mut suffix_rendered = suffix_template.clone(); + let mut snippet_session: Option = None; + let mut initial_move_left = 0usize; + + if let Some(snippet) = parse_snippet_suffix(&suffix_template) { + suffix_rendered = snippet.rendered; + + // `$0` is a cursor position, not an editable placeholder. Avoid entering snippet mode + // if there are no non-zero tabstops. + if snippet.tabstops.iter().any(|t| t.index != 0) { + let mut session = SnippetSession::new(suffix_rendered.clone(), snippet.tabstops); + session.cursor_line_id = cursor_line_id; + session.start_point = content.cursor.point; + session.active = 0; + + let target_end = session + .tabstops + .first() + .map(|t| t.range_chars.end) + .unwrap_or(session.inserted_len_chars); + + initial_move_left = session.inserted_len_chars.saturating_sub(target_end); + session.cursor_offset_chars = target_end; + session.selected = true; + snippet_session = Some(session); + } + } + + self.snap_to_bottom_on_input(cx); + let alt_is_meta = TerminalSettings::global(cx).option_as_meta; + let left = Keystroke::parse("left").unwrap(); + + let suffix = suffix_rendered.into_bytes(); + self.terminal.update(cx, move |term, _| { + term.input(suffix); + for _ in 0..initial_move_left { + term.try_keystroke(&left, alt_is_meta); + } + }); + self.snippet = snippet_session; + self.suggestions.close(); + true + } + + pub(crate) fn snippet_snapshot_for_content( + &self, + content: &TerminalContent, + cursor_line_id: Option, + cx: &App, + ) -> Option { + let snippet = self.snippet.clone()?; + if !self.suggestions_eligible_for_content(content, cx) { + return None; + } + + if let Some(expected) = snippet.cursor_line_id + && cursor_line_id != Some(expected) + { + return None; + } + + Some(snippet) + } + + pub(super) fn handle_snippet_key_down( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) -> bool { + if self.snippet.is_none() { + return false; + } + + let eligible = self + .prompt_context(cx) + .is_some_and(|prompt| self.snippet_prompt_is_eligible(&prompt, cx)); + + if !eligible { + self.snippet = None; + return false; + } + + // Any newline ends the snippet session. + if event.keystroke.key.as_str() == "enter" { + self.snippet = None; + return false; + } + + let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| { + !ch.is_empty() + && !event.keystroke.is_ime_in_progress() + && !event.keystroke.modifiers.control + && !event.keystroke.modifiers.platform + && !event.keystroke.modifiers.function + && !event.keystroke.modifiers.alt + }); + if is_plain_text + && !matches!(event.keystroke.key.as_str(), "tab" | "escape") + && let Some(ch) = event.keystroke.key_char.as_deref() + && !ch.is_empty() + { + // Snippet placeholder "selection" is local UI state; terminal line editors do not + // support replacing highlighted ranges. Treat character input as an explicit + // commit so we can delete/replace the active placeholder and keep placeholder + // highlight ranges in sync. + self.commit_text(ch, cx); + cx.notify(); + cx.stop_propagation(); + return true; + } + + let Some(session) = self.snippet.as_mut() else { + return false; + }; + + if event.keystroke.key.as_str() == "backspace" { + if session.selected { + let deleted_chars = session.delete_active_placeholder(); + session.selected = false; + + if deleted_chars > 0 { + let alt_is_meta = TerminalSettings::global(cx).option_as_meta; + let backspace = Keystroke::parse("backspace").unwrap(); + self.terminal.update(cx, move |term, _| { + for _ in 0..deleted_chars { + term.try_keystroke(&backspace, alt_is_meta); + } + }); + cx.notify(); + cx.stop_propagation(); + return true; + } + } else { + let deleted = session.backspace_one_in_active_placeholder(); + if deleted { + let alt_is_meta = TerminalSettings::global(cx).option_as_meta; + let backspace = Keystroke::parse("backspace").unwrap(); + self.terminal.update(cx, move |term, _| { + term.try_keystroke(&backspace, alt_is_meta); + }); + cx.notify(); + cx.stop_propagation(); + return true; + } + } + return false; + } + + // Any unexpected navigation/editing key cancels snippet mode to avoid + // desync with the remote line editor state. + let key = event.keystroke.key.as_str(); + let cancel = event.keystroke.modifiers.control + || event.keystroke.modifiers.platform + || event.keystroke.modifiers.function + || event.keystroke.modifiers.alt + || matches!( + key, + "left" + | "right" + | "up" + | "down" + | "home" + | "end" + | "pageup" + | "pagedown" + | "delete" + ); + if cancel { + self.snippet = None; + } + + false + } +} diff --git a/locales/en.yml b/locales/en.yml index c7094cb..2b59d96 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -82,7 +82,6 @@ NewSession: Field: AuthType: "Auth Type:" Password: "Password:" - Sftp: "SFTP:" Error: HostNoUserAtInConfigMode: "Host must not include user@ in SSH Config mode." PortRange: "Port must be 1-65535." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 18443b4..d061890 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -81,7 +81,6 @@ NewSession: Field: AuthType: "认证方式:" Password: "密码:" - Sftp: "SFTP:" Error: HostNoUserAtInConfigMode: "SSH 配置模式下 Host 不能包含 user@。" PortRange: "端口必须为 1-65535。" diff --git a/termua/Cargo.toml b/termua/Cargo.toml index 256e06f..838cac7 100644 --- a/termua/Cargo.toml +++ b/termua/Cargo.toml @@ -36,6 +36,7 @@ home.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true +thiserror.workspace = true rusqlite.workspace = true libc.workspace = true rust-i18n.workspace = true diff --git a/termua/src/panel/sessions_sidebar/icons.rs b/termua/src/panel/sessions_sidebar/icons.rs index a53629f..bba3ca2 100644 --- a/termua/src/panel/sessions_sidebar/icons.rs +++ b/termua/src/panel/sessions_sidebar/icons.rs @@ -1,8 +1,6 @@ use std::collections::BTreeMap; -use gpui::{ - AnyElement, InteractiveElement, IntoElement, ParentElement, Styled, StyledImage, div, img, px, -}; +use gpui::{AnyElement, InteractiveElement, IntoElement, ParentElement, Styled, div, px}; use gpui_common::TermuaIcon; use gpui_component::Icon; @@ -11,46 +9,22 @@ use crate::store::{Session, SessionType}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum SessionIconKind { Terminal, - Nushell, - Pwsh, - Fish, - Sh, } impl SessionIconKind { fn icon_path(self) -> TermuaIcon { - match self { - Self::Terminal => TermuaIcon::Terminal, - Self::Nushell => TermuaIcon::Nushell, - Self::Pwsh => TermuaIcon::Pwsh, - Self::Fish => TermuaIcon::Fish, - Self::Sh => TermuaIcon::Sh, - } + TermuaIcon::Terminal } pub(super) fn into_element_for_session_id(self, session_id: i64) -> AnyElement { - match self { - Self::Terminal | Self::Nushell | Self::Fish | Self::Sh => div() - .w(px(16.)) - .h(px(16.)) - .flex_shrink_0() - .debug_selector(move || match self { - Self::Terminal => format!("termua-sessions-session-icon-local-{session_id}"), - Self::Nushell => format!("termua-sessions-session-icon-nushell-{session_id}"), - Self::Fish => format!("termua-sessions-session-icon-fish-{session_id}"), - Self::Sh => format!("termua-sessions-session-icon-sh-{session_id}"), - Self::Pwsh => unreachable!(), - }) - .child(Icon::default().path(self.icon_path()).size_4()) - .into_any_element(), - Self::Pwsh => img(TermuaIcon::Pwsh) - .w(px(16.)) - .h(px(16.)) - .flex_shrink_0() - .object_fit(gpui::ObjectFit::Contain) - .debug_selector(move || format!("termua-sessions-session-icon-pwsh-{session_id}")) - .into_any_element(), - } + let _ = self; + div() + .w(px(16.)) + .h(px(16.)) + .flex_shrink_0() + .debug_selector(move || format!("termua-sessions-session-icon-local-{session_id}")) + .child(Icon::default().path(self.icon_path()).size_4()) + .into_any_element() } } @@ -61,45 +35,7 @@ pub(super) fn build_session_icon_kinds(sessions: &[Session]) -> BTreeMap SessionIconKind::Sh, - "nu" | "nushell" => SessionIconKind::Nushell, - "pwsh" | "powershell" => SessionIconKind::Pwsh, - "fish" => SessionIconKind::Fish, - _ => SessionIconKind::Terminal, - }) - .unwrap(); - - out.insert(session.id, kind); + out.insert(session.id, SessionIconKind::Terminal); } out } - -fn shell_program_basename(program: &str) -> Option<&str> { - let trimmed = program.trim(); - if trimmed.is_empty() { - return None; - } - - let last_slash = trimmed.rfind('/'); - let last_backslash = trimmed.rfind('\\'); - let start = match (last_slash, last_backslash) { - (Some(a), Some(b)) => a.max(b) + 1, - (Some(a), None) => a + 1, - (None, Some(b)) => b + 1, - (None, None) => 0, - }; - - Some(&trimmed[start..]) -} - -fn strip_exe_suffix(program: &str) -> &str { - program.strip_suffix(".exe").unwrap_or(program) -} diff --git a/termua/src/panel/sessions_sidebar/tests.rs b/termua/src/panel/sessions_sidebar/tests.rs index e05df8c..0e0b370 100644 --- a/termua/src/panel/sessions_sidebar/tests.rs +++ b/termua/src/panel/sessions_sidebar/tests.rs @@ -38,19 +38,13 @@ fn folder_icons_toggle_with_expansion(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-folder-icons-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-folder-icons"); let _guard = crate::store::tests::override_termua_db_path(db_path); crate::store::save_local_session( "Group", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -104,19 +98,13 @@ fn local_session_icon_is_debuggable(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-local-icon-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-local-icon"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -162,19 +150,13 @@ fn sessions_open_only_on_double_click(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-double-click-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-double-click"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -258,12 +240,7 @@ fn ssh_sessions_show_connecting_and_block_repeat_double_click(cx: &mut gpui::Tes gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-connecting-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-connecting"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_ssh_session_password( @@ -395,19 +372,13 @@ fn sessions_can_be_deleted_via_right_click_menu(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-delete-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-delete"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -562,7 +533,6 @@ fn build_tree_items_filters_by_query_and_keeps_ancestors() { label: "db".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm", "UTF-8", None), - shell_program: None, ssh_host: Some("db.example.com".to_string()), ssh_port: Some(22), ssh_auth_type: None, @@ -590,7 +560,6 @@ fn build_tree_items_filters_by_query_and_keeps_ancestors() { label: "api".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm", "UTF-8", None), - shell_program: None, ssh_host: Some("api.example.com".to_string()), ssh_port: Some(22), ssh_auth_type: None, @@ -644,19 +613,13 @@ fn sessions_context_menu_includes_edit_item(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-edit-menu-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-edit-menu"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -719,24 +682,18 @@ fn sessions_context_menu_includes_edit_item(cx: &mut gpui::TestAppContext) { } #[gpui::test] -fn powershell_sessions_show_pwsh_icon(cx: &mut gpui::TestAppContext) { +fn local_sessions_always_show_terminal_icon(cx: &mut gpui::TestAppContext) { cx.update(|app| { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-pwsh-icon-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-pwsh-icon"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "powershell", crate::settings::TerminalBackend::Wezterm, - "pwsh", "xterm-256color", "UTF-8", ) @@ -758,54 +715,9 @@ fn powershell_sessions_show_pwsh_icon(cx: &mut gpui::TestAppContext) { cx.run_until_parked(); let icon_selector: &'static str = - Box::leak(format!("termua-sessions-session-icon-pwsh-{session_id}").into_boxed_str()); - cx.debug_bounds(icon_selector) - .expect("expected PowerShell sessions to render pwsh.svg as their icon"); -} - -#[gpui::test] -fn nushell_sessions_show_nushell_icon(cx: &mut gpui::TestAppContext) { - cx.update(|app| { - gpui_component::init(app); - }); - - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-nushell-icon-{}", - std::process::id() - )); - 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); - - let session_id = crate::store::save_local_session( - "local", - "nushell", - crate::settings::TerminalBackend::Wezterm, - "nu", - "xterm-256color", - "UTF-8", - ) - .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(400.)), - ), - move |_, _| div().size_full().child(root), - ); - cx.run_until_parked(); - - let icon_selector: &'static str = - Box::leak(format!("termua-sessions-session-icon-nushell-{session_id}").into_boxed_str()); + Box::leak(format!("termua-sessions-session-icon-local-{session_id}").into_boxed_str()); cx.debug_bounds(icon_selector) - .expect("expected Nushell sessions to render nushell.png as their icon"); + .expect("expected local sessions to render the generic terminal icon"); } #[gpui::test] @@ -814,12 +726,7 @@ fn blank_area_right_click_shows_new_session_menu_item(cx: &mut gpui::TestAppCont gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-blank-new-session-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-blank-new-session"); let _guard = crate::store::tests::override_termua_db_path(db_path); let (root, cx) = cx.add_window_view(|window, cx| { @@ -872,19 +779,13 @@ fn folder_right_click_shows_new_session_menu_item(cx: &mut gpui::TestAppContext) gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-folder-new-session-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-folder-new-session"); let _guard = crate::store::tests::override_termua_db_path(db_path); crate::store::save_local_session( "Group", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -948,23 +849,13 @@ fn sidebar_shows_load_error_when_disk_sessions_cannot_be_parsed(cx: &mut gpui::T 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 db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-load-error"); 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", ) @@ -1002,19 +893,13 @@ fn session_labels_do_not_wrap_when_sidebar_is_narrow(cx: &mut gpui::TestAppConte gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-nowrap-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-nowrap"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "Group", "This is a very long session name that should not wrap", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1071,23 +956,13 @@ fn reload_coalesces_while_previous_reload_is_in_flight(cx: &mut gpui::TestAppCon gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-reload-coalesce-{}-{}", - 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 db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-reload-coalesce"); let _guard = crate::store::tests::override_termua_db_path(db_path); crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1168,23 +1043,13 @@ fn repeated_delete_requests_for_same_session_are_ignored(cx: &mut gpui::TestAppC gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-sidebar-delete-dedupe-{}-{}", - 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 db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-delete-dedupe"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_local_session( "local", "bash", crate::settings::TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs index 270ff68..19de97d 100644 --- a/termua/src/panel/terminal_panel.rs +++ b/termua/src/panel/terminal_panel.rs @@ -620,29 +620,6 @@ mod tests { ); } - #[test] - fn local_tabs_use_shell_display_name_when_present() { - let mut env = HashMap::new(); - env.insert("TERMUA_SHELL".into(), "/bin/bash".into()); - let mut counts = HashMap::new(); - assert_eq!( - local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(), - "bash" - ); - - env.insert("TERMUA_SHELL".into(), "nu".into()); - assert_eq!( - local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(), - "nushell" - ); - - env.insert("TERMUA_SHELL".into(), "pwsh".into()); - assert_eq!( - local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(), - "powershell" - ); - } - #[test] fn local_tabs_fall_back_to_local_prefix_without_shell() { let mut counts = HashMap::new(); diff --git a/termua/src/session/store.rs b/termua/src/session/store.rs index 280ca25..a860763 100644 --- a/termua/src/session/store.rs +++ b/termua/src/session/store.rs @@ -146,9 +146,6 @@ pub struct Session { pub backend: TerminalBackend, pub env: Option>, - // Local - pub shell_program: Option, - // SSH pub ssh_host: Option, pub ssh_port: Option, @@ -287,7 +284,6 @@ fn init_schema(conn: &Connection) -> anyhow::Result<()> { label TEXT NOT NULL, backend TEXT NOT NULL, session_env TEXT, - shell_program TEXT, ssh_host TEXT, ssh_port INTEGER, ssh_auth_type TEXT, @@ -520,7 +516,6 @@ struct SessionWrite<'a> { label: &'a str, backend: TerminalBackend, env: Vec, - shell_program: Option<&'a str>, ssh_host: Option<&'a str>, ssh_port: Option, ssh_auth_type: Option, @@ -546,7 +541,6 @@ impl<'a> SessionWrite<'a> { group_path: &'a str, label: &'a str, backend: TerminalBackend, - shell_program: &'a str, term: &'a str, colorterm: Option<&'a str>, charset: &'a str, @@ -558,7 +552,6 @@ impl<'a> SessionWrite<'a> { label, backend, env: merge_terminal_fields_into_env(term, colorterm, charset, env), - shell_program: Some(shell_program), ssh_host: None, ssh_port: None, ssh_auth_type: None, @@ -607,7 +600,6 @@ impl<'a> SessionWrite<'a> { label, backend, env: merge_terminal_fields_into_env(term, colorterm, charset, env), - shell_program: None, ssh_host: Some(host), ssh_port: Some(port), ssh_auth_type: Some(SshAuthType::Password), @@ -654,7 +646,6 @@ impl<'a> SessionWrite<'a> { label, backend, env: merge_terminal_fields_into_env(term, colorterm, charset, env), - shell_program: None, ssh_host: Some(host), ssh_port: Some(port), ssh_auth_type: Some(SshAuthType::Config), @@ -696,7 +687,6 @@ impl<'a> SessionWrite<'a> { label, backend, env: merge_terminal_fields_into_env(term, None, charset, Vec::new()), - shell_program: None, ssh_host: None, ssh_port: None, ssh_auth_type: None, @@ -760,12 +750,11 @@ fn insert_session_row(conn: &Connection, session: &SessionWrite<'_>) -> anyhow:: INSERT INTO sessions ( protocol, group_path, label, backend, session_env, - shell_program, ssh_host, ssh_port, ssh_auth_type, ssh_user, ssh_credential_username, ssh_tcp_nodelay, ssh_tcp_keepalive, ssh_proxy_mode, ssh_proxy_command, ssh_proxy_workdir, ssh_proxy_env, ssh_proxy_jump, serial_port, serial_baud, serial_data_bits, serial_parity, serial_stop_bits, serial_flow_control - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22) "#, params![ protocol_to_str(&session.protocol), @@ -773,7 +762,6 @@ fn insert_session_row(conn: &Connection, session: &SessionWrite<'_>) -> anyhow:: session.label, backend_to_str(session.backend), session_env_json, - session.shell_program, session.ssh_host, session.ssh_port.map(|value| value as i64), session.ssh_auth_type.as_ref().map(ssh_auth_to_str), @@ -815,25 +803,24 @@ fn update_session_row( label = ?4, backend = ?5, session_env = ?6, - shell_program = ?7, - ssh_host = ?8, - ssh_port = ?9, - ssh_auth_type = ?10, - ssh_user = ?11, + ssh_host = ?7, + ssh_port = ?8, + ssh_auth_type = ?9, + ssh_user = ?10, ssh_credential_username = NULL, - ssh_tcp_nodelay = ?12, - ssh_tcp_keepalive = ?13, - ssh_proxy_mode = ?14, - ssh_proxy_command = ?15, - ssh_proxy_workdir = ?16, - ssh_proxy_env = ?17, - ssh_proxy_jump = ?18, - serial_port = ?19, - serial_baud = ?20, - serial_data_bits = ?21, - serial_parity = ?22, - serial_stop_bits = ?23, - serial_flow_control = ?24, + ssh_tcp_nodelay = ?11, + ssh_tcp_keepalive = ?12, + ssh_proxy_mode = ?13, + ssh_proxy_command = ?14, + ssh_proxy_workdir = ?15, + ssh_proxy_env = ?16, + ssh_proxy_jump = ?17, + serial_port = ?18, + serial_baud = ?19, + serial_data_bits = ?20, + serial_parity = ?21, + serial_stop_bits = ?22, + serial_flow_control = ?23, updated_at = unixepoch() WHERE id = ?1 "#, @@ -844,7 +831,6 @@ fn update_session_row( session.label, backend_to_str(session.backend), session_env_json, - session.shell_program, session.ssh_host, session.ssh_port.map(|value| value as i64), session.ssh_auth_type.as_ref().map(ssh_auth_to_str), @@ -889,27 +875,16 @@ pub fn save_local_session( group_path: &str, label: &str, backend: TerminalBackend, - shell_program: &str, term: &str, charset: &str, ) -> anyhow::Result { - save_local_session_with_env( - group_path, - label, - backend, - shell_program, - term, - None, - charset, - Vec::new(), - ) + save_local_session_with_env(group_path, label, backend, term, None, charset, Vec::new()) } pub fn save_local_session_with_env( group_path: &str, label: &str, backend: TerminalBackend, - shell_program: &str, term: &str, colorterm: Option<&str>, charset: &str, @@ -918,16 +893,7 @@ pub fn save_local_session_with_env( let conn = open()?; insert_session_row( &conn, - &SessionWrite::local( - group_path, - label, - backend, - shell_program, - term, - colorterm, - charset, - env, - ), + &SessionWrite::local(group_path, label, backend, term, colorterm, charset, env), ) .context("insert local session") } @@ -1198,7 +1164,6 @@ pub fn load_all_sessions() -> anyhow::Result> { SELECT id, protocol, group_path, label, backend, session_env, - shell_program, ssh_host, ssh_port, ssh_auth_type, ssh_user, ssh_credential_username, ssh_tcp_nodelay, ssh_tcp_keepalive, ssh_proxy_mode, ssh_proxy_command, ssh_proxy_workdir, ssh_proxy_env, ssh_proxy_jump, @@ -1227,7 +1192,6 @@ pub fn load_session(id: i64) -> anyhow::Result> { SELECT id, protocol, group_path, label, backend, session_env, - shell_program, ssh_host, ssh_port, ssh_auth_type, ssh_user, ssh_credential_username, ssh_tcp_nodelay, ssh_tcp_keepalive, ssh_proxy_mode, ssh_proxy_command, ssh_proxy_workdir, ssh_proxy_env, ssh_proxy_jump, @@ -1261,26 +1225,25 @@ fn parse_session_row(row: &rusqlite::Row<'_>, include_password: bool) -> rusqlit label: row.get(3)?, backend, env: parse_session_env(row)?, - shell_program: row.get(6)?, - ssh_host: row.get(7)?, - ssh_port: row.get::<_, Option>(8)?.map(|v| v as u16), + ssh_host: row.get(6)?, + ssh_port: row.get::<_, Option>(7)?.map(|v| v as u16), ssh_auth_type, - ssh_user: row.get(10)?, - ssh_credential_username: row.get(11)?, + ssh_user: row.get(9)?, + ssh_credential_username: row.get(10)?, ssh_password, - ssh_tcp_nodelay: row.get::<_, i64>(12)? != 0, - ssh_tcp_keepalive: row.get::<_, i64>(13)? != 0, + ssh_tcp_nodelay: row.get::<_, i64>(11)? != 0, + ssh_tcp_keepalive: row.get::<_, i64>(12)? != 0, ssh_proxy_mode: parse_ssh_proxy_mode(row)?, - ssh_proxy_command: row.get(15)?, - ssh_proxy_workdir: row.get(16)?, + ssh_proxy_command: row.get(14)?, + ssh_proxy_workdir: row.get(15)?, ssh_proxy_env: parse_ssh_proxy_env(row)?, ssh_proxy_jump: parse_ssh_proxy_jump(row)?, - serial_port: row.get(19)?, - serial_baud: row.get::<_, Option>(20)?.map(|v| v as u32), - serial_data_bits: row.get::<_, Option>(21)?.map(|v| v as u8), + serial_port: row.get(18)?, + serial_baud: row.get::<_, Option>(19)?.map(|v| v as u32), + serial_data_bits: row.get::<_, Option>(20)?.map(|v| v as u8), serial_parity: parse_serial_parity(row)?, serial_stop_bits: parse_serial_stop_bits(row)?, serial_flow_control: parse_serial_flow_control(row)?, @@ -1300,11 +1263,11 @@ fn parse_backend(row: &rusqlite::Row<'_>) -> rusqlite::Result { } fn parse_ssh_auth_type(row: &rusqlite::Row<'_>) -> rusqlite::Result> { - let ssh_auth_s: Option = row.get(9)?; + let ssh_auth_s: Option = row.get(8)?; ssh_auth_s .map(|s| { ssh_auth_from_str(&s) - .map_err(|e| from_sql_text_parse_error(9, ParseError(e.to_string()))) + .map_err(|e| from_sql_text_parse_error(8, ParseError(e.to_string()))) }) .transpose() } @@ -1315,41 +1278,41 @@ fn parse_session_env(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result> { - let ssh_proxy_mode_s: Option = row.get(14)?; + let ssh_proxy_mode_s: Option = row.get(13)?; ssh_proxy_mode_s .map(|s| { ssh_proxy_mode_from_str(&s) - .map_err(|e| from_sql_text_parse_error(14, ParseError(e.to_string()))) + .map_err(|e| from_sql_text_parse_error(13, ParseError(e.to_string()))) }) .transpose() } fn parse_ssh_proxy_env(row: &rusqlite::Row<'_>) -> rusqlite::Result>> { - let ssh_proxy_env_s: Option = row.get(17)?; + let ssh_proxy_env_s: Option = row.get(16)?; Ok(ssh_proxy_env_s .and_then(|raw| serde_json_lenient::from_str::>(&raw).ok())) } fn parse_ssh_proxy_jump(row: &rusqlite::Row<'_>) -> rusqlite::Result>> { - let ssh_proxy_jump_s: Option = row.get(18)?; + let ssh_proxy_jump_s: Option = row.get(17)?; Ok(ssh_proxy_jump_s.and_then(|raw| serde_json_lenient::from_str::>(&raw).ok())) } fn parse_serial_parity(row: &rusqlite::Row<'_>) -> rusqlite::Result> { - let serial_parity_s: Option = row.get(22)?; + let serial_parity_s: Option = row.get(21)?; serial_parity_s .map(|s| { serial_parity_from_str(&s) - .map_err(|e| from_sql_text_parse_error(22, ParseError(e.to_string()))) + .map_err(|e| from_sql_text_parse_error(21, ParseError(e.to_string()))) }) .transpose() } fn parse_serial_stop_bits(row: &rusqlite::Row<'_>) -> rusqlite::Result> { - row.get::<_, Option>(23)? + row.get::<_, Option>(22)? .map(|v| { serial_stop_bits_from_i64(v) - .map_err(|e| from_sql_int_parse_error(23, ParseError(e.to_string()))) + .map_err(|e| from_sql_int_parse_error(22, ParseError(e.to_string()))) }) .transpose() } @@ -1357,11 +1320,11 @@ fn parse_serial_stop_bits(row: &rusqlite::Row<'_>) -> rusqlite::Result, ) -> rusqlite::Result> { - let serial_flow_control_s: Option = row.get(24)?; + let serial_flow_control_s: Option = row.get(23)?; serial_flow_control_s .map(|s| { serial_flow_control_from_str(&s) - .map_err(|e| from_sql_text_parse_error(24, ParseError(e.to_string()))) + .map_err(|e| from_sql_text_parse_error(23, ParseError(e.to_string()))) }) .transpose() } @@ -1407,7 +1370,6 @@ pub fn update_local_session( group_path: &str, label: &str, backend: TerminalBackend, - shell_program: &str, term: &str, charset: &str, ) -> anyhow::Result<()> { @@ -1416,7 +1378,6 @@ pub fn update_local_session( group_path, label, backend, - shell_program, term, None, charset, @@ -1429,7 +1390,6 @@ pub fn update_local_session_with_env( group_path: &str, label: &str, backend: TerminalBackend, - shell_program: &str, term: &str, colorterm: Option<&str>, charset: &str, @@ -1440,16 +1400,7 @@ pub fn update_local_session_with_env( update_session_row( &conn, id, - &SessionWrite::local( - group_path, - label, - backend, - shell_program, - term, - colorterm, - charset, - env, - ), + &SessionWrite::local(group_path, label, backend, term, colorterm, charset, env), ) .context("update local session") } @@ -1774,7 +1725,7 @@ pub(crate) mod tests { TermuaDbPathOverrideGuard { prev } } - fn unique_test_db_path(name: &str) -> PathBuf { + pub fn unique_test_db_path(name: &str) -> PathBuf { static NEXT_ID: AtomicUsize = AtomicUsize::new(0); let nonce = NEXT_ID.fetch_add(1, Ordering::Relaxed); @@ -1804,7 +1755,6 @@ pub(crate) mod tests { "local>dev", "bash", TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1874,6 +1824,7 @@ pub(crate) mod tests { } assert!(names.iter().any(|n| n == "session_env")); + assert!(!names.iter().any(|n| n == "shell_program")); assert!(!names.iter().any(|n| n == "term")); assert!(!names.iter().any(|n| n == "charset")); assert!(!names.iter().any(|n| n == "colorterm")); @@ -1891,7 +1842,6 @@ pub(crate) mod tests { "local", "bash", TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1947,7 +1897,6 @@ pub(crate) mod tests { "local", "bash", TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1958,6 +1907,28 @@ pub(crate) mod tests { assert!(load_session(id).unwrap().is_none()); } + #[test] + fn ssh_password_sessions_preserve_user_on_initial_save() { + let db_path = unique_test_db_path("ssh-user-save"); + let _guard = override_termua_db_path(db_path); + + let ssh_id = save_ssh_session_password( + "ssh", + "prod", + TerminalBackend::Wezterm, + "example.com", + 22, + "iamazy", + "pw123", + "xterm-256color", + "UTF-8", + ) + .unwrap(); + + let ssh = load_session(ssh_id).unwrap().unwrap(); + assert_eq!(ssh.ssh_user.as_deref(), Some("iamazy")); + } + #[test] fn sessions_can_be_updated_in_sqlite() { let db_path = unique_test_db_path("update"); @@ -1967,7 +1938,6 @@ pub(crate) mod tests { "local", "bash", TerminalBackend::Wezterm, - "bash", "xterm-256color", "UTF-8", ) @@ -1978,7 +1948,6 @@ pub(crate) mod tests { "local>dev", "zsh", TerminalBackend::Alacritty, - "zsh", "screen-256color", "ASCII", ) @@ -1988,7 +1957,6 @@ pub(crate) mod tests { assert_eq!(local.group_path, "local>dev"); assert_eq!(local.label, "zsh"); assert_eq!(local.backend, TerminalBackend::Alacritty); - assert_eq!(local.shell_program.as_deref(), Some("zsh")); assert_eq!(local.term(), "screen-256color"); assert_eq!(local.charset(), "ASCII"); @@ -2086,9 +2054,8 @@ pub(crate) mod tests { let id = save_local_session_with_env( "local", - "fish", + "bash", TerminalBackend::Wezterm, - "fish", "xterm-256color", Some("truecolor"), "UTF-8", diff --git a/termua/src/shell_integration.rs b/termua/src/shell_integration.rs index d5fd2ed..9957eea 100644 --- a/termua/src/shell_integration.rs +++ b/termua/src/shell_integration.rs @@ -1,18 +1,14 @@ use std::collections::HashMap; use gpui_term::shell::{ - ShellKind, TERMUA_FISH_INIT_ENV_KEY, TERMUA_NU_CONFIG_ENV_KEY, TERMUA_NU_ENV_CONFIG_ENV_KEY, - TERMUA_PWSH_INIT_ENV_KEY, TERMUA_SHELL_ENV_KEY, pick_shell_program_from_env_or_else, - shell_kind, + ShellKind, TERMUA_SHELL_ENV_KEY, pick_shell_program_from_env_or_else, shell_kind, }; -#[cfg(unix)] +#[cfg(target_os = "linux")] const OSC133_BASH: &str = include_str!("../../assets/shell/termua-osc133.bash"); -#[cfg(unix)] +#[cfg(target_os = "macos")] const OSC133_ZSH: &str = include_str!("../../assets/shell/termua-osc133.zsh"); -const OSC133_FISH: &str = include_str!("../../assets/shell/termua-osc133.fish"); -const OSC133_NU: &str = include_str!("../../assets/shell/termua-osc133.nu"); -const NU_ENV_CONFIG: &str = include_str!("../../assets/shell/termua-env.nu"); +#[cfg(any(windows, test))] const OSC133_PWSH: &str = include_str!("../../assets/shell/termua-osc133.ps1"); pub(crate) fn maybe_inject_local_shell_osc133( @@ -24,15 +20,18 @@ pub(crate) fn maybe_inject_local_shell_osc133( }; match shell_kind(&shell_program) { + #[cfg(target_os = "linux")] ShellKind::Bash => maybe_inject_local_bash_osc133(env, terminal_id), + #[cfg(target_os = "macos")] ShellKind::Zsh => maybe_inject_local_zsh_osc133(env, terminal_id), - ShellKind::Fish => maybe_inject_local_fish_osc133(env, terminal_id), - ShellKind::Nu => maybe_inject_local_nu_osc133(env, terminal_id), - ShellKind::PowerShell => maybe_inject_local_powershell_osc133(env, terminal_id), + #[cfg(windows)] + ShellKind::Pwsh => maybe_inject_local_pwsh_osc133(env, terminal_id), ShellKind::Other => env, + _ => env, } } +#[cfg(target_os = "linux")] pub(crate) fn maybe_inject_local_bash_osc133( env: HashMap, terminal_id: usize, @@ -71,6 +70,7 @@ pub(crate) fn maybe_inject_local_bash_osc133( } } +#[cfg(target_os = "macos")] pub(crate) fn maybe_inject_local_zsh_osc133( env: HashMap, terminal_id: usize, @@ -115,69 +115,8 @@ pub(crate) fn maybe_inject_local_zsh_osc133( } } -pub(crate) fn maybe_inject_local_fish_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - let mut env = env; - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - if !is_fish_program(&shell_program) { - return env; - } - - match write_fish_init(terminal_id) { - Ok(init_path) => { - env.insert("SHELL".to_string(), shell_program.clone()); - env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); - env.insert( - TERMUA_FISH_INIT_ENV_KEY.to_string(), - init_path.to_string_lossy().to_string(), - ); - } - Err(err) => { - log::warn!("termua: failed to inject OSC133 fish integration: {err:#}"); - } - } - - env -} - -pub(crate) fn maybe_inject_local_nu_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - let mut env = env; - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - if !is_nu_program(&shell_program) { - return env; - } - - match write_nu_config_dir(terminal_id) { - Ok((config_path, env_config_path)) => { - env.insert("SHELL".to_string(), shell_program.clone()); - env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); - env.insert( - TERMUA_NU_CONFIG_ENV_KEY.to_string(), - config_path.to_string_lossy().to_string(), - ); - env.insert( - TERMUA_NU_ENV_CONFIG_ENV_KEY.to_string(), - env_config_path.to_string_lossy().to_string(), - ); - } - Err(err) => { - log::warn!("termua: failed to inject OSC133 nushell integration: {err:#}"); - } - } - - env -} - -pub(crate) fn maybe_inject_local_powershell_osc133( +#[cfg(windows)] +pub(crate) fn maybe_inject_local_pwsh_osc133( env: HashMap, terminal_id: usize, ) -> HashMap { @@ -194,12 +133,12 @@ pub(crate) fn maybe_inject_local_powershell_osc133( env.insert("SHELL".to_string(), shell_program.clone()); env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); env.insert( - TERMUA_PWSH_INIT_ENV_KEY.to_string(), + gpui_term::shell::TERMUA_PWSH_INIT_ENV_KEY.to_string(), init_path.to_string_lossy().to_string(), ); } Err(err) => { - log::warn!("termua: failed to inject OSC133 powershell integration: {err:#}"); + log::warn!("termua: failed to inject OSC133 pwsh integration: {err:#}"); } } @@ -210,24 +149,17 @@ fn selected_shell_program_for_env(env: &HashMap) -> Option bool { matches!(shell_kind(program), ShellKind::Bash) } -#[cfg(unix)] +#[cfg(target_os = "macos")] fn is_zsh_program(program: &str) -> bool { matches!(shell_kind(program), ShellKind::Zsh) } -fn is_fish_program(program: &str) -> bool { - matches!(shell_kind(program), ShellKind::Fish) -} - -fn is_nu_program(program: &str) -> bool { - matches!(shell_kind(program), ShellKind::Nu) -} - +#[cfg(any(windows, test))] fn is_pwsh_program(program: &str) -> bool { let program = program.trim(); if program.is_empty() { @@ -290,18 +222,13 @@ fn set_private_file_permissions(_path: &std::path::Path) -> anyhow::Result<()> { Ok(()) } -#[cfg(unix)] +#[cfg(target_os = "macos")] fn set_private_dir_permissions(path: &std::path::Path) -> anyhow::Result<()> { use std::{fs, os::unix::fs::PermissionsExt as _}; fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; Ok(()) } -#[cfg(not(unix))] -fn set_private_dir_permissions(_path: &std::path::Path) -> anyhow::Result<()> { - Ok(()) -} - fn unique_shell_path(name: &str) -> anyhow::Result { use std::time::{SystemTime, UNIX_EPOCH}; @@ -314,7 +241,7 @@ fn unique_shell_path(name: &str) -> anyhow::Result { Ok(dir.join(format!("{name}-{pid}-{ts}"))) } -#[cfg(unix)] +#[cfg(target_os = "linux")] fn write_bash_rcfile(terminal_id: usize) -> anyhow::Result { use std::fs; @@ -334,7 +261,7 @@ fn write_bash_rcfile(terminal_id: usize) -> anyhow::Result { Ok(rc_path) } -#[cfg(unix)] +#[cfg(target_os = "macos")] fn write_zsh_dotdir(terminal_id: usize) -> anyhow::Result { use std::fs; @@ -381,16 +308,7 @@ fn write_zsh_dotdir(terminal_id: usize) -> anyhow::Result { Ok(zdotdir) } -fn write_fish_init(terminal_id: usize) -> anyhow::Result { - use std::fs; - - let init_path = - unique_shell_path(&format!("termua-fish-init-{terminal_id}"))?.with_extension("fish"); - fs::write(&init_path, OSC133_FISH)?; - set_private_file_permissions(&init_path)?; - Ok(init_path) -} - +#[cfg(windows)] fn write_powershell_init(terminal_id: usize) -> anyhow::Result { use std::fs; @@ -401,70 +319,11 @@ fn write_powershell_init(terminal_id: usize) -> anyhow::Result) -> String { - OSC133_NU.replace( - "__TERMUA_ORIG_CONFIG__", - &nushell_source_literal(orig_config_path), - ) -} - -fn render_nu_env_config(orig_env_path: Option<&std::path::Path>) -> String { - NU_ENV_CONFIG.replace( - "__TERMUA_ORIG_ENV__", - &nushell_source_literal(orig_env_path), - ) -} - -fn nushell_source_literal(path: Option<&std::path::Path>) -> String { - path.map(|path| format!("{:?}", path.to_string_lossy())) - .unwrap_or_else(|| "null".to_string()) -} - -fn existing_nushell_user_config_file(file_name: &str) -> Option { - let base_config_dir = std::env::var_os("XDG_CONFIG_HOME") - .or_else(|| std::env::var_os("APPDATA")) - .or_else(|| { - std::env::var_os("HOME").map(|home| { - std::path::PathBuf::from(home) - .join(".config") - .into_os_string() - }) - }) - .map(std::path::PathBuf::from)?; - let path = base_config_dir.join("nushell").join(file_name); - path.exists().then_some(path) -} - -fn write_nu_config_dir( - terminal_id: usize, -) -> anyhow::Result<(std::path::PathBuf, std::path::PathBuf)> { - use std::fs; - - let config_dir = unique_shell_path(&format!("termua-nu-config-{terminal_id}"))?; - fs::create_dir_all(&config_dir)?; - set_private_dir_permissions(&config_dir)?; - - let env_config_path = config_dir.join("env.nu"); - let config_path = config_dir.join("config.nu"); - - let orig_env_path = existing_nushell_user_config_file("env.nu"); - let orig_config_path = existing_nushell_user_config_file("config.nu"); - let env_config = render_nu_env_config(orig_env_path.as_deref()); - let config = render_nu_config(orig_config_path.as_deref()); - - fs::write(&env_config_path, env_config)?; - fs::write(&config_path, config)?; - set_private_file_permissions(&env_config_path)?; - set_private_file_permissions(&config_path)?; - - Ok((config_path, env_config_path)) -} - #[cfg(test)] mod tests { use super::*; - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn detects_bash_program_by_basename() { assert!(is_bash_program("bash")); @@ -472,7 +331,7 @@ mod tests { assert!(!is_bash_program("zsh")); } - #[cfg(unix)] + #[cfg(target_os = "macos")] #[test] fn detects_zsh_program_by_basename() { assert!(is_zsh_program("zsh")); @@ -480,20 +339,6 @@ mod tests { assert!(!is_zsh_program("bash")); } - #[test] - fn detects_fish_program_by_basename() { - assert!(is_fish_program("fish")); - assert!(is_fish_program("/usr/bin/fish")); - assert!(!is_fish_program("bash")); - } - - #[test] - fn detects_nu_program_by_basename() { - assert!(is_nu_program("nu")); - assert!(is_nu_program("/usr/bin/nu")); - assert!(!is_nu_program("bash")); - } - #[test] fn detects_pwsh_program_by_basename() { assert!(is_pwsh_program("pwsh")); @@ -502,7 +347,7 @@ mod tests { assert!(!is_pwsh_program("bash")); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn injection_writes_rcfile_and_sets_env() { // First, ensure the underlying filesystem write succeeds (helps provide @@ -524,7 +369,7 @@ mod tests { ); } - #[cfg(unix)] + #[cfg(target_os = "macos")] #[test] fn zsh_injection_writes_dotdir_and_sets_env() { let dotdir = write_zsh_dotdir(7).expect("write dotdir"); @@ -543,97 +388,9 @@ mod tests { ); } - #[cfg(unix)] + #[cfg(windows)] #[test] - fn fish_injection_writes_init_and_sets_env() { - let init = write_fish_init(7).expect("write fish init"); - assert!(init.exists(), "fish init should exist"); - - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "fish".to_string()); - - let env = maybe_inject_local_shell_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), Some("fish")); - let init_path = env - .get("TERMUA_FISH_INIT") - .expect("expected TERMUA_FISH_INIT to be set"); - assert!( - std::path::Path::new(init_path).exists(), - "fish init should exist" - ); - } - - #[cfg(unix)] - #[test] - fn fish_init_disables_reflow_only_in_termua() { - let init = write_fish_init(7).expect("write fish init"); - let contents = std::fs::read_to_string(init).expect("read fish init"); - - assert!( - contents.contains("set -g fish_handle_reflow 0"), - "fish init should disable fish_handle_reflow for Termua sessions" - ); - } - - #[cfg(unix)] - #[test] - fn nu_injection_writes_configs_and_sets_env() { - let (config, env_config) = write_nu_config_dir(7).expect("write nu config dir"); - assert!(config.exists(), "nu config should exist"); - assert!(env_config.exists(), "nu env config should exist"); - - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "nu".to_string()); - - let env = maybe_inject_local_shell_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), Some("nu")); - assert!( - std::path::Path::new( - env.get("TERMUA_NU_CONFIG") - .expect("expected TERMUA_NU_CONFIG to be set") - ) - .exists(), - "nu config should exist" - ); - assert!( - std::path::Path::new( - env.get("TERMUA_NU_ENV_CONFIG") - .expect("expected TERMUA_NU_ENV_CONFIG to be set") - ) - .exists(), - "nu env config should exist" - ); - } - - #[test] - fn renders_nu_config_with_const_source_path_and_non_deprecated_get_flag() { - let config = render_nu_config(None); - - assert!(config.contains("hooks.pre_prompt")); - assert!(config.contains("hooks.pre_execution")); - assert!(config.contains("133;A")); - assert!(config.contains("133;B")); - assert!(config.contains("133;C")); - assert!(config.contains("133;D;($__termua_exit)")); - assert!(config.contains("const __termua_orig_config = null")); - assert!(config.contains("source $__termua_orig_config")); - assert!(config.contains("get -o hooks.pre_prompt")); - assert!(config.contains("get -o hooks.pre_execution")); - assert!(!config.contains("let __termua_orig_config")); - assert!(!config.contains("get -i")); - } - - #[test] - fn renders_nu_env_config_with_const_source_env_path() { - let env_config = render_nu_env_config(None); - - assert!(env_config.contains("const __termua_orig_env = null")); - assert!(env_config.contains("source-env $__termua_orig_env")); - assert!(!env_config.contains("let __termua_orig_env")); - } - - #[test] - fn powershell_injection_writes_init_and_sets_env() { + fn pwsh_injection_writes_init_and_sets_env() { let init = write_powershell_init(7).expect("write powershell init"); assert!(init.exists(), "powershell init should exist"); @@ -661,7 +418,64 @@ mod tests { assert_eq!(env.get("TERMUA_PWSH_INIT").map(String::as_str), None); } - #[cfg(unix)] + #[cfg(target_os = "linux")] + #[test] + fn linux_only_integrates_bash() { + let mut bash_env = HashMap::new(); + bash_env.insert("SHELL".to_string(), "bash".to_string()); + let bash_env = maybe_inject_local_shell_osc133(bash_env, 7); + assert_eq!( + bash_env.get("TERMUA_SHELL").map(String::as_str), + Some("bash") + ); + assert!(bash_env.contains_key("TERMUA_BASH_RCFILE")); + + let mut zsh_env = HashMap::new(); + zsh_env.insert("SHELL".to_string(), "zsh".to_string()); + let zsh_env = maybe_inject_local_shell_osc133(zsh_env, 7); + assert_eq!(zsh_env.get("TERMUA_SHELL").map(String::as_str), None); + assert_eq!(zsh_env.get("ZDOTDIR").map(String::as_str), None); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_only_integrates_zsh() { + let mut zsh_env = HashMap::new(); + zsh_env.insert("SHELL".to_string(), "zsh".to_string()); + let zsh_env = maybe_inject_local_shell_osc133(zsh_env, 7); + assert_eq!(zsh_env.get("TERMUA_SHELL").map(String::as_str), Some("zsh")); + assert!(zsh_env.contains_key("ZDOTDIR")); + + let mut bash_env = HashMap::new(); + bash_env.insert("SHELL".to_string(), "bash".to_string()); + let bash_env = maybe_inject_local_shell_osc133(bash_env, 7); + assert_eq!(bash_env.get("TERMUA_SHELL").map(String::as_str), None); + assert_eq!(bash_env.get("TERMUA_BASH_RCFILE").map(String::as_str), None); + } + + #[cfg(windows)] + #[test] + fn windows_only_integrates_pwsh() { + let mut pwsh_env = HashMap::new(); + pwsh_env.insert("SHELL".to_string(), "pwsh".to_string()); + let pwsh_env = maybe_inject_local_shell_osc133(pwsh_env, 7); + assert_eq!( + pwsh_env.get("TERMUA_SHELL").map(String::as_str), + Some("pwsh") + ); + assert!(pwsh_env.contains_key("TERMUA_PWSH_INIT")); + + let mut powershell_env = HashMap::new(); + powershell_env.insert("SHELL".to_string(), "powershell".to_string()); + let powershell_env = maybe_inject_local_shell_osc133(powershell_env, 7); + assert_eq!(powershell_env.get("TERMUA_SHELL").map(String::as_str), None); + assert_eq!( + powershell_env.get("TERMUA_PWSH_INIT").map(String::as_str), + None + ); + } + + #[cfg(target_os = "macos")] #[test] fn zsh_osc133_script_avoids_readonly_status_parameter() { assert!( @@ -671,10 +485,33 @@ mod tests { assert!(OSC133_ZSH.contains("local exit_status=$?")); } - #[cfg(unix)] + #[cfg(target_os = "linux")] + #[test] + fn osc133_shell_scripts_emit_prompt_markers() { + for script in [OSC133_BASH, OSC133_PWSH] { + assert!( + script.contains("133;A") || script.contains("\"A\""), + "expected script to emit prompt start marker" + ); + assert!( + script.contains("133;B") || script.contains("\"B\""), + "expected script to emit prompt end marker" + ); + assert!( + script.contains("133;C") || script.contains("\"C\""), + "expected script to emit command start marker" + ); + assert!( + script.contains("133;D") || script.contains("\"D;"), + "expected script to emit command end marker" + ); + } + } + + #[cfg(target_os = "macos")] #[test] fn osc133_shell_scripts_emit_prompt_markers() { - for script in [OSC133_BASH, OSC133_ZSH, OSC133_FISH, OSC133_NU, OSC133_PWSH] { + for script in [OSC133_ZSH, OSC133_PWSH] { assert!( script.contains("133;A") || script.contains("\"A\""), "expected script to emit prompt start marker" diff --git a/termua/src/window/main_window/actions.rs b/termua/src/window/main_window/actions.rs index 53ac104..bcc5c4d 100644 --- a/termua/src/window/main_window/actions.rs +++ b/termua/src/window/main_window/actions.rs @@ -1,55 +1,16 @@ //! TermuaWindow behavior and event handling. -use std::{ - collections::HashMap, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; +mod sftp; +mod sharing; +mod ssh; +mod terminal; -use gpui::{ - App, AppContext, Context, FocusHandle, Focusable, InteractiveElement, IntoElement, - ParentElement, ReadGlobal, SharedString, Styled, Window, div, px, -}; -use gpui_common::{TermuaIcon, format_bytes}; -use gpui_component::{ - Icon, - button::{Button, ButtonVariants}, - h_flex, v_flex, -}; -use gpui_dock::{DockPlacement, PanelView}; -use gpui_term::{ - Authentication, CursorShape, Event as TerminalEvent, PtySource, RemoteBackendEvent, - SerialOptions, SshOptions, TerminalBuilder, TerminalSettings, TerminalType, TerminalView, - UserInput as TerminalUserInput, - remote::{RemoteFrame, RemoteInputEvent, RemoteSnapshot, RemoteTerminalContent}, -}; -use gpui_transfer::{ - AUTO_DISMISS_AFTER, TransferCenterState, TransferKind, TransferProgress, TransferStatus, - TransferTask, -}; +use gpui::{App, Context, InteractiveElement, ParentElement, Window, div}; use rust_i18n::t; -use smol::Timer; use super::TermuaWindow; use crate::{ - NewLocalTerminal, OpenSftp, PendingCommand, PlayCast, SerialParams, SshParams, TermuaAppState, - env::{build_terminal_env, cast_player_child_env}, - lock_screen, notification, - panel::{PanelKind, SshErrorPanel, TerminalPanel, terminal_panel_tab_name}, - sharing::{ - ClientToRelay as RelayClientToRelay, HostShare, RelaySharingState, - RelayToClient as RelayRelayToClient, ReleaseControl, RequestControl, RevokeControl, - StartSharing, StopSharing, ViewerShare, compose_share_key, connect_relay, gen_join_key, - gen_room_id, parse_share_key, - }, - ssh::{ - SshHostKeyMismatchDetails, dedupe_tab_label, default_known_hosts_path, - parse_ssh_host_key_mismatch, remove_known_host_entry, ssh_connect_failure_message, - ssh_proxy_from_session, ssh_tab_tooltip, ssh_target_label, - }, + NewLocalTerminal, OpenSftp, PendingCommand, PlayCast, TermuaAppState, lock_screen, notification, }; impl TermuaWindow { @@ -105,488 +66,6 @@ impl TermuaWindow { cx.quit(); } } - - fn sftp_upload_panel_prefix(panel_id: usize) -> String { - format!("sftp-upload-{panel_id}-") - } - - fn sftp_upload_group_id(panel_id: usize, transfer_id: u64) -> String { - format!("sftp-upload-{panel_id}-{transfer_id}") - } - - fn sftp_upload_task_id(panel_id: usize, transfer_id: u64, file_index: usize) -> String { - format!( - "{}-{file_index}", - Self::sftp_upload_group_id(panel_id, transfer_id) - ) - } - - pub(crate) fn subscribe_terminal_events_for_messages( - &mut self, - terminal: gpui::Entity, - panel_id: usize, - tab_label: gpui::SharedString, - window: &mut Window, - cx: &mut Context, - ) { - let sub = cx.subscribe_in( - &terminal, - window, - 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, - window: &mut Window, - cx: &mut Context, - ) { - if Self::handle_terminal_event_toast(event, window, cx) { - return; - } - 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( - event: &TerminalEvent, - window: &mut Window, - cx: &mut Context, - ) -> bool { - match event { - TerminalEvent::Toast { - level, - title, - detail, - } => { - let kind = match level { - gpui::PromptLevel::Info => crate::notification::MessageKind::Info, - gpui::PromptLevel::Warning => crate::notification::MessageKind::Warning, - gpui::PromptLevel::Critical => crate::notification::MessageKind::Error, - }; - let message = match detail.as_deref() { - Some(detail) if !detail.trim().is_empty() => format!("{title}\n{detail}"), - _ => title.clone(), - }; - crate::notification::notify(kind, message, window, cx); - true - } - _ => false, - } - } - - fn upsert_transfer(task: TransferTask, cx: &mut Context) { - if cx.try_global::().is_none() { - return; - } - cx.global_mut::().upsert(task); - } - - fn remove_transfer(id: &str, cx: &mut Context) { - if cx.try_global::().is_none() { - return; - } - cx.global_mut::().remove(id); - } - - fn sftp_upload_progress(sent: u64, total: u64) -> TransferProgress { - if total > 0 { - TransferProgress::Determinate((sent as f32 / total as f32).clamp(0.0, 1.0)) - } else { - TransferProgress::Determinate(1.0) - } - } - - fn build_sftp_upload_task( - panel_id: usize, - transfer_id: u64, - file_index: usize, - file: &str, - status: TransferStatus, - sent: u64, - total: u64, - cancel: Option<&Arc>, - ) -> (String, TransferTask) { - let group_id = Self::sftp_upload_group_id(panel_id, transfer_id); - let id = Self::sftp_upload_task_id(panel_id, transfer_id, file_index); - let task = TransferTask::new(id.clone(), SharedString::from(file.to_string())) - .with_group(group_id, Some(file_index.saturating_add(1))) - .with_kind(TransferKind::Upload) - .with_status(status) - .with_progress(Self::sftp_upload_progress(sent, total)) - .with_bytes(Some(sent), Some(total).filter(|t| *t > 0)); - - let task = if let Some(cancel) = cancel { - task.with_cancel_token(Arc::clone(cancel)) - } else { - task - }; - - (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; - } - - match terminal_panel.kind() { - PanelKind::Recorder => false, - PanelKind::Ssh => !terminal_panel - .terminal_view() - .read(cx) - .terminal - .read(cx) - .has_exited(), - PanelKind::Local | PanelKind::Serial => true, - } - }) else { - return; - }; - - self.close_terminal_panel(panel, window, cx); - } - - fn handle_terminal_event_sftp_upload( - panel_id: usize, - tab_label: &SharedString, - event: &TerminalEvent, - window: &mut Window, - cx: &mut Context, - ) -> bool { - match event { - TerminalEvent::SftpUploadFileProgress { - transfer_id, - file_index, - file, - sent, - total, - cancel, - } => { - Self::handle_sftp_upload_file_progress( - panel_id, - transfer_id, - file_index, - file, - sent, - total, - cancel, - cx, - ); - true - } - TerminalEvent::SftpUploadFinished { files, total_bytes } => { - Self::handle_sftp_upload_finished(tab_label, files.len(), *total_bytes, window, cx); - true - } - TerminalEvent::SftpUploadFileFinished { - transfer_id, - file_index, - file, - bytes, - } => { - Self::handle_sftp_upload_file_finished( - panel_id, - transfer_id, - file_index, - file, - *bytes, - cx, - ); - true - } - TerminalEvent::SftpUploadCancelled => { - Self::handle_sftp_upload_cancelled(panel_id, tab_label, window, cx); - true - } - TerminalEvent::SftpUploadFileCancelled { - transfer_id, - file_index, - file, - sent, - total, - } => { - Self::handle_sftp_upload_file_cancelled( - panel_id, - transfer_id, - file_index, - file, - sent, - total, - cx, - ); - true - } - _ => false, - } - } - - fn handle_sftp_upload_file_progress( - panel_id: usize, - transfer_id: &u64, - file_index: &usize, - file: &str, - sent: &u64, - total: &u64, - cancel: &Arc, - cx: &mut Context, - ) { - let (_id, task) = Self::build_sftp_upload_task( - panel_id, - *transfer_id, - *file_index, - file, - TransferStatus::InProgress, - *sent, - *total, - Some(cancel), - ); - Self::upsert_transfer(task, cx); - } - - fn handle_sftp_upload_finished( - tab_label: &SharedString, - file_count: usize, - total_bytes: u64, - window: &mut Window, - cx: &mut Context, - ) { - notification::notify( - notification::MessageKind::Success, - Self::sftp_upload_finished_message(tab_label, file_count, total_bytes), - window, - cx, - ); - } - - fn handle_sftp_upload_file_finished( - panel_id: usize, - transfer_id: &u64, - file_index: &usize, - file: &str, - bytes: u64, - cx: &mut Context, - ) { - let (id, task) = Self::build_sftp_upload_task( - panel_id, - *transfer_id, - *file_index, - file, - TransferStatus::Finished, - bytes, - bytes, - None, - ); - Self::upsert_transfer(task, cx); - Self::schedule_transfer_auto_dismiss(id, cx); - } - - fn handle_sftp_upload_cancelled( - panel_id: usize, - tab_label: &SharedString, - window: &mut Window, - cx: &mut Context, - ) { - notification::notify( - notification::MessageKind::Warning, - Self::sftp_upload_cancelled_message(tab_label), - window, - cx, - ); - - if cx.try_global::().is_some() { - cx.global_mut::() - .remove_groups_with_prefix(Self::sftp_upload_panel_prefix(panel_id).as_str()); - } - } - - fn sftp_upload_count_label(file_count: usize) -> String { - match file_count { - 1 => "1 file".to_string(), - n => format!("{n} files"), - } - } - - fn sftp_upload_finished_message( - tab_label: &SharedString, - file_count: usize, - total_bytes: u64, - ) -> String { - format!( - "[{}] Upload via SFTP complete: {}, {}", - tab_label.as_ref(), - Self::sftp_upload_count_label(file_count), - format_bytes(total_bytes) - ) - } - - fn sftp_upload_cancelled_message(tab_label: &SharedString) -> String { - format!("[{}] Upload via SFTP cancelled", tab_label.as_ref()) - } - - fn sharing_started_message(room_id: &str, join_key: &str) -> String { - format!( - "Sharing started\nShare Key: {}", - compose_share_key(room_id, join_key) - ) - } - - fn handle_sftp_upload_file_cancelled( - panel_id: usize, - transfer_id: &u64, - file_index: &usize, - file: &str, - sent: &u64, - total: &u64, - cx: &mut Context, - ) { - let (id, task) = Self::build_sftp_upload_task( - panel_id, - *transfer_id, - *file_index, - file, - TransferStatus::Cancelled, - *sent, - *total, - None, - ); - Self::upsert_transfer(task, cx); - Self::schedule_transfer_auto_dismiss(id, cx); - } - - fn schedule_transfer_auto_dismiss(id: String, cx: &mut Context) { - cx.spawn(async move |this, cx| { - Timer::after(AUTO_DISMISS_AFTER).await; - let _ = this.update(cx, |_this, cx| { - Self::remove_transfer(id.as_str(), cx); - cx.notify(); - }); - }) - .detach(); - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, atomic::AtomicBool}; - - use gpui_transfer::TransferStatus; - - use super::TermuaWindow; - - #[test] - fn sftp_transfer_keys_are_stable() { - assert_eq!(TermuaWindow::sftp_upload_panel_prefix(7), "sftp-upload-7-"); - assert_eq!(TermuaWindow::sftp_upload_group_id(7, 9), "sftp-upload-7-9"); - assert_eq!( - TermuaWindow::sftp_upload_task_id(7, 9, 2), - "sftp-upload-7-9-2" - ); - } - - #[test] - fn sftp_upload_finished_message_uses_consistent_copy() { - let label = gpui::SharedString::from("ssh 1"); - assert_eq!( - TermuaWindow::sftp_upload_finished_message(&label, 1, 1024), - "[ssh 1] Upload via SFTP complete: 1 file, 1.0 KiB" - ); - assert_eq!( - TermuaWindow::sftp_upload_finished_message(&label, 2, 2048), - "[ssh 1] Upload via SFTP complete: 2 files, 2.0 KiB" - ); - } - - #[test] - fn sftp_upload_cancelled_message_uses_consistent_copy() { - let label = gpui::SharedString::from("ssh 1"); - assert_eq!( - TermuaWindow::sftp_upload_cancelled_message(&label), - "[ssh 1] Upload via SFTP cancelled" - ); - } - - #[test] - fn sharing_started_message_uses_share_key_copy() { - assert_eq!( - TermuaWindow::sharing_started_message("AbC234xYz", "k3Y9a2"), - "Sharing started\nShare Key: AbC234xYz-k3Y9a2" - ); - } - - #[test] - fn sftp_upload_task_builder_normalizes_progress_and_keys() { - let cancel = Arc::new(AtomicBool::new(false)); - let (id, task) = TermuaWindow::build_sftp_upload_task( - 7, - 9, - 2, - "foo.txt", - TransferStatus::InProgress, - 12, - 0, - Some(&cancel), - ); - - assert_eq!(id, "sftp-upload-7-9-2"); - assert_eq!(task.group_id.as_deref(), Some("sftp-upload-7-9")); - assert_eq!(task.group_total, Some(3)); - assert_eq!(task.status, TransferStatus::InProgress); - assert_eq!(task.bytes_done, Some(12)); - assert_eq!(task.bytes_total, None); - assert_eq!( - task.progress, - gpui_transfer::TransferProgress::Determinate(1.0) - ); - assert!(task.cancel.is_some()); - } } impl TermuaWindow { @@ -711,2428 +190,4 @@ impl TermuaWindow { self.open_sftp_for_terminal_view(focused, window, cx); } - - pub(super) fn on_start_sharing( - &mut self, - _: &StartSharing, - window: &mut Window, - cx: &mut Context, - ) { - if cx.global::().locked() { - return; - } - if !crate::sharing::sharing_feature_enabled(cx) { - notification::notify_deferred( - notification::MessageKind::Warning, - "Sharing is disabled in Settings.", - window, - cx, - ); - return; - } - let Some(focused) = self - .focused_terminal_view - .as_ref() - .and_then(|v| v.upgrade()) - else { - notification::notify_deferred( - notification::MessageKind::Error, - "No active terminal to share.", - window, - cx, - ); - return; - }; - - let terminal_view_id = focused.entity_id(); - if cx - .global::() - .hosts - .contains_key(&terminal_view_id) - { - return; - } - - let relay_url = crate::sharing::effective_relay_url(cx); - let room_id = gen_room_id(); - let join_key = gen_join_key(); - cx.spawn_in(window, async move |this, window| { - let conn = connect_relay( - &relay_url, - RelayClientToRelay::Register { - room_id: room_id.clone(), - join_key: join_key.clone(), - ttl_secs: Some(30 * 60), - }, - ) - .await; - - let _ = this.update_in(window, move |this, window, cx| match conn { - Ok(conn) => { - let dirty = Arc::new(AtomicBool::new(false)); - let selection_dirty = Arc::new(AtomicBool::new(false)); - - cx.global_mut::().hosts.insert( - terminal_view_id, - HostShare { - room_id: room_id.clone(), - controller_id: None, - pending_request: false, - conn: conn.clone(), - seq: 0, - dirty: dirty.clone(), - selection_dirty: selection_dirty.clone(), - }, - ); - - let terminal = focused.read(cx).terminal.clone(); - this.subscribe_host_terminal_for_sharing_frames( - terminal, - dirty.clone(), - selection_dirty.clone(), - window, - cx, - ); - - this.send_host_snapshot(&focused, cx); - this.spawn_relay_pump_for_host(terminal_view_id, focused.clone(), window, cx); - this.spawn_relay_publisher_for_host( - terminal_view_id, - focused.clone(), - conn, - dirty, - selection_dirty, - window, - cx, - ); - - notification::notify_deferred( - notification::MessageKind::Info, - Self::sharing_started_message(&room_id, &join_key), - window, - cx, - ); - } - Err(err) => { - notification::notify_deferred( - notification::MessageKind::Error, - format!("Start sharing failed: {err:#}"), - window, - cx, - ); - } - }); - }) - .detach(); - } - - pub(super) fn on_stop_sharing( - &mut self, - _: &StopSharing, - window: &mut Window, - cx: &mut Context, - ) { - if cx.global::().locked() { - return; - } - let Some(focused) = self - .focused_terminal_view - .as_ref() - .and_then(|v| v.upgrade()) - else { - return; - }; - let terminal_view_id = focused.entity_id(); - - let host = cx - .global_mut::() - .hosts - .remove(&terminal_view_id); - if let Some(host) = host { - host.conn.send(RelayClientToRelay::Stop { - room_id: host.room_id.clone(), - }); - host.conn.close(); - notification::notify_deferred( - notification::MessageKind::Info, - "Stopped sharing.", - window, - cx, - ); - } - } - - pub(super) fn on_request_control( - &mut self, - _: &RequestControl, - window: &mut Window, - cx: &mut Context, - ) { - if cx.global::().locked() { - return; - } - let Some(focused) = self - .focused_terminal_view - .as_ref() - .and_then(|v| v.upgrade()) - else { - return; - }; - let terminal_view_id = focused.entity_id(); - let Some(viewer) = cx - .global::() - .viewers - .get(&terminal_view_id) - .cloned() - else { - return; - }; - let Some(viewer_id) = viewer.viewer_id.lock().ok().and_then(|v| v.clone()) else { - notification::notify_deferred( - notification::MessageKind::Warning, - "Not joined yet.", - window, - cx, - ); - return; - }; - viewer.conn.send(RelayClientToRelay::Request { - room_id: viewer.room_id.clone(), - viewer_id, - viewer_label: None, - }); - } - - pub(super) fn on_release_control( - &mut self, - _: &ReleaseControl, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(focused) = self - .focused_terminal_view - .as_ref() - .and_then(|v| v.upgrade()) - else { - return; - }; - let terminal_view_id = focused.entity_id(); - let Some(viewer) = cx - .global::() - .viewers - .get(&terminal_view_id) - .cloned() - else { - return; - }; - let Some(viewer_id) = viewer.viewer_id.lock().ok().and_then(|v| v.clone()) else { - return; - }; - viewer.conn.send(RelayClientToRelay::Release { - room_id: viewer.room_id.clone(), - viewer_id, - }); - } - - pub(super) fn on_revoke_control( - &mut self, - _: &RevokeControl, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(focused) = self - .focused_terminal_view - .as_ref() - .and_then(|v| v.upgrade()) - else { - return; - }; - let terminal_view_id = focused.entity_id(); - let Some(host) = cx - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - else { - return; - }; - // Ensure local host state does not keep denying new requests as "busy". - crate::sharing::clear_host_control_state(host); - host.conn.send(RelayClientToRelay::Revoked { - room_id: host.room_id.clone(), - }); - } - - fn send_host_snapshot( - &mut self, - terminal_view: &gpui::Entity, - cx: &mut Context, - ) { - let terminal_view_id = terminal_view.entity_id(); - let terminal = terminal_view.read(cx).terminal.clone(); - let term_read = terminal.read(cx); - let viewport_line_numbers = Self::host_viewport_line_numbers(&term_read); - let payload = gpui_term::remote::RemoteTerminalContent::from_local( - term_read.last_content(), - term_read.total_lines(), - term_read.viewport_lines(), - viewport_line_numbers, - ); - - let Ok(payload_json) = serde_json::to_value(payload) else { - return; - }; - - let (room_id, seq, conn) = { - let Some(host) = cx - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - else { - return; - }; - host.seq = host.seq.wrapping_add(1); - (host.room_id.clone(), host.seq, host.conn.clone()) - }; - - conn.send(RelayClientToRelay::Snapshot { - room_id, - seq, - payload: payload_json, - }); - } - - fn send_host_frame( - &mut self, - terminal_view: &gpui::Entity, - cx: &mut Context, - ) { - let terminal_view_id = terminal_view.entity_id(); - let terminal = terminal_view.read(cx).terminal.clone(); - let term_read = terminal.read(cx); - let viewport_line_numbers = Self::host_viewport_line_numbers(&term_read); - let payload = gpui_term::remote::RemoteTerminalContent::from_local( - term_read.last_content(), - term_read.total_lines(), - term_read.viewport_lines(), - viewport_line_numbers, - ); - - let Ok(payload_json) = serde_json::to_value(payload) else { - return; - }; - - let (room_id, seq, conn) = { - let Some(host) = cx - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - else { - return; - }; - host.seq = host.seq.wrapping_add(1); - (host.room_id.clone(), host.seq, host.conn.clone()) - }; - - conn.send(RelayClientToRelay::Frame { - room_id, - seq, - payload: payload_json, - }); - } - - fn host_viewport_line_numbers(terminal: &gpui_term::Terminal) -> Vec> { - let total_lines = terminal.total_lines(); - let viewport_lines = terminal.viewport_lines().max(1); - let display_offset = terminal.last_content().display_offset; - let viewport_top = total_lines - .saturating_sub(viewport_lines) - .saturating_sub(display_offset); - let rows = terminal.last_content().terminal_bounds.num_lines().max(1); - terminal.logical_line_numbers_from_top(viewport_top, rows) - } - - fn send_host_selection_update( - &mut self, - terminal_view: &gpui::Entity, - cx: &mut Context, - ) { - let terminal_view_id = terminal_view.entity_id(); - let terminal = terminal_view.read(cx).terminal.clone(); - let term_read = terminal.read(cx); - let payload = - gpui_term::remote::RemoteSelectionUpdate::from_local(term_read.last_content()); - - let Ok(payload_json) = serde_json::to_value(payload) else { - return; - }; - - let (room_id, seq, conn) = { - let Some(host) = cx - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - else { - return; - }; - host.seq = host.seq.wrapping_add(1); - (host.room_id.clone(), host.seq, host.conn.clone()) - }; - - conn.send(RelayClientToRelay::Selection { - room_id, - seq, - payload: payload_json, - }); - } - - fn spawn_relay_pump_for_host( - &mut self, - terminal_view_id: gpui::EntityId, - terminal_view: gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(host) = cx - .global::() - .hosts - .get(&terminal_view_id) - .cloned() - else { - return; - }; - - let host_conn = host.conn; - cx.spawn_in(window, async move |this, window| { - while let Some(msg) = host_conn.recv().await { - let _ = this.update_in(window, |this, window, cx| { - this.handle_host_relay_message( - terminal_view_id, - &terminal_view, - msg, - window, - cx, - ); - cx.notify(); - }); - } - }) - .detach(); - } - - fn spawn_relay_pump_for_viewer( - &mut self, - terminal_view_id: gpui::EntityId, - terminal: gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(viewer) = cx - .global::() - .viewers - .get(&terminal_view_id) - .cloned() - else { - return; - }; - - let viewer_conn = viewer.conn; - let controlled = viewer.controlled; - let viewer_id = viewer.viewer_id; - - cx.spawn_in(window, async move |this, window| { - while let Some(msg) = viewer_conn.recv().await { - let should_close = matches!(msg, RelayRelayToClient::Error { .. }); - let _ = this.update_in(window, |this, window, cx| { - this.handle_viewer_relay_message( - terminal_view_id, - &terminal, - &viewer_id, - &controlled, - msg, - window, - cx, - ); - cx.notify(); - }); - if should_close { - viewer_conn.close(); - break; - } - } - }) - .detach(); - } - - fn spawn_relay_publisher_for_host( - &mut self, - terminal_view_id: gpui::EntityId, - terminal_view: gpui::Entity, - conn: crate::sharing::RelayConn, - dirty: Arc, - selection_dirty: Arc, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |this, window| { - let mut tick: u32 = 0; - let mut last_frame_at = Instant::now() - .checked_sub(Duration::from_secs(3600)) - .unwrap_or_else(Instant::now); - let mut last_selection_at = last_frame_at; - let mut last_display_offset: Option = None; - loop { - Timer::after(Duration::from_millis(20)).await; - tick = tick.wrapping_add(1); - - let keep_running = this - .update_in(window, |this, _window, cx| { - if !cx - .global::() - .hosts - .contains_key(&terminal_view_id) - { - return false; - } - - if tick.is_multiple_of(50) { - conn.send(RelayClientToRelay::Ping); - } - - // Host-initiated scroll does not emit `TerminalEvent::Wakeup`, so detect it - // by observing `display_offset` and mark frames dirty. - let display_offset = terminal_view - .read(cx) - .terminal - .read(cx) - .last_content() - .display_offset; - match last_display_offset { - Some(prev) if prev != display_offset => { - last_display_offset = Some(display_offset); - dirty.store(true, Ordering::Relaxed); - } - None => { - last_display_offset = Some(display_offset); - } - _ => {} - } - - // Only send frames after terminal changes, with a conservative rate cap. - if dirty.load(Ordering::Relaxed) - && last_frame_at.elapsed() >= Duration::from_millis(50) - { - dirty.store(false, Ordering::Relaxed); - last_frame_at = Instant::now(); - this.send_host_frame(&terminal_view, cx); - } - - // Selection updates can be frequent; send smaller messages with a separate - // cap. - if selection_dirty.load(Ordering::Relaxed) - && last_selection_at.elapsed() >= Duration::from_millis(33) - { - selection_dirty.store(false, Ordering::Relaxed); - last_selection_at = Instant::now(); - this.send_host_selection_update(&terminal_view, cx); - } - true - }) - .unwrap_or(false); - - if !keep_running { - break; - } - } - }) - .detach(); - } - - fn subscribe_host_terminal_for_sharing_frames( - &mut self, - terminal: gpui::Entity, - dirty: Arc, - selection_dirty: Arc, - window: &mut Window, - cx: &mut Context, - ) { - let sub = cx.subscribe_in( - &terminal, - window, - move |_this, _terminal, event, _window, _cx| match event { - TerminalEvent::Wakeup => { - dirty.store(true, Ordering::Relaxed); - } - TerminalEvent::SelectionsChanged => { - selection_dirty.store(true, Ordering::Relaxed); - } - _ => {} - }, - ); - self._subscriptions.push(sub); - } - - fn handle_host_relay_message( - &mut self, - terminal_view_id: gpui::EntityId, - terminal_view: &gpui::Entity, - msg: RelayRelayToClient, - window: &mut Window, - cx: &mut Context, - ) { - let Some(host) = cx - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - else { - return; - }; - match msg { - RelayRelayToClient::CtrlRequest { - room_id: _, - viewer_id, - viewer_label, - } => { - if host.controller_id.is_some() || host.pending_request { - host.conn.send(RelayClientToRelay::Denied { - room_id: host.room_id.clone(), - viewer_id, - reason: "busy".to_string(), - }); - return; - } - host.pending_request = true; - self.open_control_confirm_dialog( - terminal_view_id, - viewer_id, - viewer_label, - window, - cx, - ); - } - RelayRelayToClient::CtrlRelease { - room_id: _, - viewer_id, - } => { - if host.controller_id.as_deref() == Some(&viewer_id) { - host.conn.send(RelayClientToRelay::Released { - room_id: host.room_id.clone(), - viewer_id, - }); - host.controller_id = None; - } - } - RelayRelayToClient::CtrlReleased { - room_id: _, - viewer_id, - } => { - // The relay may clear control immediately on viewer release to avoid "busy" races. - // Treat CtrlReleased as authoritative and idempotent. - if host.controller_id.as_deref() == Some(&viewer_id) { - host.controller_id = None; - } - host.pending_request = false; - } - RelayRelayToClient::InputEvent { - room_id: _, - viewer_id, - payload, - } => { - let is_controller = host.controller_id.as_deref() == Some(&viewer_id); - let dirty = host.dirty.clone(); - let selection_dirty = host.selection_dirty.clone(); - if !is_controller { - return; - } - let Ok(ev) = serde_json::from_value::(payload) else { - return; - }; - let is_selection = matches!(ev, RemoteInputEvent::SetSelectionRange { .. }); - self.apply_remote_input_to_host_terminal(terminal_view_id, terminal_view, ev, cx); - if is_selection { - selection_dirty.store(true, Ordering::Relaxed); - } else { - dirty.store(true, Ordering::Relaxed); - } - } - RelayRelayToClient::Error { code: _, message } => { - notification::notify_deferred( - notification::MessageKind::Error, - message, - window, - cx, - ); - } - RelayRelayToClient::Ok - | RelayRelayToClient::Pong - | RelayRelayToClient::Joined { .. } - | RelayRelayToClient::Snapshot { .. } - | RelayRelayToClient::Frame { .. } - | RelayRelayToClient::Selection { .. } - | RelayRelayToClient::CtrlDenied { .. } - | RelayRelayToClient::CtrlGranted { .. } - | RelayRelayToClient::CtrlRevoked { .. } => {} - } - } - - fn handle_viewer_relay_message( - &mut self, - terminal_view_id: gpui::EntityId, - terminal: &gpui::Entity, - viewer_id: &Arc>>, - controlled: &Arc, - msg: RelayRelayToClient, - window: &mut Window, - cx: &mut Context, - ) { - match msg { - RelayRelayToClient::Joined { - room_id: _, - viewer_id: id, - } => { - if let Ok(mut guard) = viewer_id.lock() { - *guard = Some(id); - } - notification::notify_deferred( - notification::MessageKind::Info, - "Joined sharing.", - window, - cx, - ); - } - RelayRelayToClient::Snapshot { - room_id: _, - seq, - payload, - } => { - let Ok(content) = serde_json::from_value::(payload) else { - return; - }; - crate::sharing::apply_remote_snapshot( - terminal, - RemoteSnapshot { seq, content }, - cx, - ); - } - RelayRelayToClient::Frame { - room_id: _, - seq, - payload, - } => { - let Ok(content) = serde_json::from_value::(payload) else { - return; - }; - crate::sharing::apply_remote_frame(terminal, RemoteFrame { seq, content }, cx); - } - RelayRelayToClient::Selection { - room_id: _, - seq: _, - payload, - } => { - let Ok(update) = - serde_json::from_value::(payload) - else { - return; - }; - crate::sharing::apply_remote_selection_update(terminal, update, cx); - } - RelayRelayToClient::CtrlGranted { - room_id: _, - viewer_id: granted, - } => { - let mine = viewer_id.lock().ok().and_then(|v| v.clone()); - if mine.as_deref() != Some(&granted) { - return; - } - controlled.store(true, Ordering::Relaxed); - terminal.update(cx, |term, cx| { - term.dispatch_backend_event( - Box::new(RemoteBackendEvent::SetControlled(true)), - cx, - ); - }); - notification::notify_deferred( - notification::MessageKind::Info, - "Control granted.", - window, - cx, - ); - } - RelayRelayToClient::CtrlDenied { room_id: _, reason } => { - controlled.store(false, Ordering::Relaxed); - terminal.update(cx, |term, cx| { - term.dispatch_backend_event( - Box::new(RemoteBackendEvent::SetControlled(false)), - cx, - ); - }); - notification::notify_deferred( - notification::MessageKind::Warning, - format!("Control denied: {reason}"), - window, - cx, - ); - } - RelayRelayToClient::CtrlReleased { - room_id: _, - viewer_id: released, - } => { - let mine = viewer_id.lock().ok().and_then(|v| v.clone()); - if mine.as_deref() != Some(&released) { - return; - } - controlled.store(false, Ordering::Relaxed); - terminal.update(cx, |term, cx| { - term.dispatch_backend_event( - Box::new(RemoteBackendEvent::SetControlled(false)), - cx, - ); - }); - notification::notify_deferred( - notification::MessageKind::Info, - "Control released.", - window, - cx, - ); - } - RelayRelayToClient::CtrlRevoked { room_id: _ } => { - controlled.store(false, Ordering::Relaxed); - terminal.update(cx, |term, cx| { - term.dispatch_backend_event( - Box::new(RemoteBackendEvent::SetControlled(false)), - cx, - ); - }); - notification::notify_deferred( - notification::MessageKind::Warning, - "Control revoked.", - window, - cx, - ); - } - RelayRelayToClient::Error { code: _, message } => { - cx.global_mut::() - .viewers - .remove(&terminal_view_id); - controlled.store(false, Ordering::Relaxed); - terminal.update(cx, |term, cx| { - term.dispatch_backend_event( - Box::new(RemoteBackendEvent::SetControlled(false)), - cx, - ); - }); - notification::notify_deferred( - notification::MessageKind::Error, - message, - window, - cx, - ); - } - RelayRelayToClient::Ok - | RelayRelayToClient::Pong - | RelayRelayToClient::CtrlRequest { .. } - | RelayRelayToClient::CtrlRelease { .. } - | RelayRelayToClient::InputEvent { .. } => {} - } - } - - fn apply_remote_input_to_host_terminal( - &mut self, - terminal_view_id: gpui::EntityId, - terminal_view: &gpui::Entity, - ev: gpui_term::remote::RemoteInputEvent, - cx: &mut Context, - ) { - if terminal_view.entity_id() != terminal_view_id { - return; - } - - let terminal = terminal_view.read(cx).terminal.clone(); - match ev { - gpui_term::remote::RemoteInputEvent::Keystroke { keystroke } => { - if let Ok(k) = gpui::Keystroke::parse(&keystroke) { - let alt_is_meta = TerminalSettings::global(cx).option_as_meta; - terminal.update(cx, |t, _cx| { - t.try_keystroke(&k, alt_is_meta); - }); - } - } - gpui_term::remote::RemoteInputEvent::Paste { text } => { - terminal.update(cx, |t, _| t.paste(&text)); - } - gpui_term::remote::RemoteInputEvent::Text { text } => { - terminal.update(cx, |t, _| t.input(text.into_bytes())); - } - gpui_term::remote::RemoteInputEvent::ScrollLines { delta } => { - terminal.update(cx, |t, _| { - if delta > 0 { - t.scroll_up_by(delta as usize); - } else if delta < 0 { - t.scroll_down_by((-delta) as usize); - } - }); - } - gpui_term::remote::RemoteInputEvent::ScrollToTop => { - terminal.update(cx, |t, _| t.scroll_to_top()); - } - gpui_term::remote::RemoteInputEvent::ScrollToBottom => { - terminal.update(cx, |t, _| t.scroll_to_bottom()); - } - gpui_term::remote::RemoteInputEvent::SetSelectionRange { range } => { - terminal.update(cx, |t, _| { - t.set_selection_range(range.map(gpui_term::SelectionRange::from)); - }); - } - } - } - - fn open_control_confirm_dialog( - &mut self, - terminal_view_id: gpui::EntityId, - viewer_id: String, - viewer_label: Option, - window: &mut Window, - cx: &mut Context, - ) { - let Some(Some(root)) = window.root::() else { - return; - }; - - let label = viewer_label.unwrap_or_else(|| viewer_id.clone()); - root.update(cx, |root, cx| { - root.open_dialog( - move |dialog, _window, _app| { - let detail = format!("Viewer requested control:\n{label}"); - dialog - .title("Request Control".to_string()) - .child(gpui_component::text::TextView::markdown( - "termua-sharing-ctrl-request", - detail, - )) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text("Grant".to_string()) - .cancel_text("Deny".to_string()), - ) - .on_ok({ - let viewer_id = viewer_id.clone(); - move |_, _window, app| { - if let Some(host) = app - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - { - host.pending_request = false; - host.controller_id = Some(viewer_id.clone()); - host.conn.send(RelayClientToRelay::Granted { - room_id: host.room_id.clone(), - viewer_id: viewer_id.clone(), - }); - } - true - } - }) - .on_cancel({ - let viewer_id = viewer_id.clone(); - move |_, _window, app| { - if let Some(host) = app - .global_mut::() - .hosts - .get_mut(&terminal_view_id) - { - host.pending_request = false; - host.conn.send(RelayClientToRelay::Denied { - room_id: host.room_id.clone(), - viewer_id: viewer_id.clone(), - reason: "denied".to_string(), - }); - } - true - } - }) - .confirm() - }, - window, - cx, - ); - }); - } - - fn open_join_sharing_dialog(&mut self, window: &mut Window, cx: &mut Context) { - use gpui_component::input::{Input, InputState}; - - let Some(Some(root)) = window.root::() else { - return; - }; - - if !crate::sharing::sharing_feature_enabled(cx) { - notification::notify_deferred( - notification::MessageKind::Warning, - "Sharing is disabled in Settings.", - window, - cx, - ); - return; - } - - // Important: keep input state stable across renders. Creating `InputState` inside the - // dialog builder closure can cause it to be re-created on each re-render, making typing - // appear to "do nothing". - let relay_input = cx.new(|cx| InputState::new(window, cx)); - let share_key_input = - cx.new(|cx| InputState::new(window, cx).placeholder("AbC123xYz-k3Y9a1")); - let relay_url = crate::sharing::effective_relay_url(cx); - relay_input.update(cx, |state, cx| state.set_value(&relay_url, window, cx)); - - root.update(cx, |root, cx| { - let relay_input = relay_input.clone(); - let share_key_input = share_key_input.clone(); - - root.open_dialog( - move |dialog, _window, _app| { - dialog - .title("Join Sharing".to_string()) - .w(px(540.0)) - .child( - v_flex() - .gap_2() - .child("Relay URL".to_string()) - .child(Input::new(&relay_input)) - .child("Share Key".to_string()) - .child(Input::new(&share_key_input)), - ) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text("Join".to_string()) - .cancel_text("Cancel".to_string()), - ) - .on_ok({ - let relay_input = relay_input.clone(); - let share_key_input = share_key_input.clone(); - - move |_, window, app| { - let relay_url = relay_input.read(app).value().trim().to_string(); - let share_key = - share_key_input.read(app).value().trim().to_string(); - if relay_url.is_empty() || share_key.is_empty() { - notification::notify_app( - notification::MessageKind::Warning, - "Relay URL / Share Key cannot be empty.", - window, - app, - ); - return false; - } - if !relay_url.starts_with("ws://") - && !relay_url.starts_with("wss://") - { - notification::notify_app( - notification::MessageKind::Warning, - "Relay URL must start with ws:// or wss://", - window, - app, - ); - return false; - } - let (room_id, join_key) = match parse_share_key(&share_key) { - Ok(parsed) => parsed, - Err(err) => { - notification::notify_app( - notification::MessageKind::Warning, - format!("Invalid Share Key: {err}"), - window, - app, - ); - return false; - } - }; - if room_id.is_empty() || join_key.is_empty() { - notification::notify_app( - notification::MessageKind::Warning, - "Invalid Share Key.", - window, - app, - ); - return false; - } - app.global_mut::().pending_command( - PendingCommand::JoinRelaySharing { - relay_url, - room_id, - join_key, - }, - ); - app.refresh_windows(); - let _ = window; - true - } - }) - .confirm() - }, - window, - cx, - ); - }); - - let focus = share_key_input.read(cx).focus_handle(cx); - window.defer(cx, move |window, cx| window.focus(&focus, cx)); - } - - fn add_local_terminal(&mut self, window: &mut Window, cx: &mut Context) { - self.add_local_terminal_with_params(TerminalType::WezTerm, HashMap::new(), window, cx); - } - - fn open_cast_player_picker(&mut self, window: &mut Window, cx: &mut Context) { - use gpui::PathPromptOptions; - - let picker = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("Select cast file to play".into()), - }); - - cx.spawn_in(window, async move |view, window| { - let Ok(Ok(Some(mut paths))) = picker.await else { - return; - }; - let Some(path) = paths.pop() else { - return; - }; - - let _ = view.update_in(window, |this, window, cx| { - this.open_cast_player_tab(path, window, cx); - }); - }) - .detach(); - } - - fn open_cast_player_tab( - &mut self, - cast_path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let playback_speed = cx - .try_global::() - .map(crate::settings::RecordingSettings::playback_speed_or_default) - .unwrap_or(1.0); - let env = cast_player_child_env(&cast_path, playback_speed); - - let panel = - self.build_terminal_panel(PanelKind::Recorder, TerminalType::WezTerm, env, window, cx); - - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel.clone()) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - } - - fn add_local_terminal_with_params( - &mut self, - backend_type: TerminalType, - env: HashMap, - window: &mut Window, - cx: &mut Context, - ) { - let panel = self.build_terminal_panel(PanelKind::Local, backend_type, env, window, cx); - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - // `DockArea` will re-render itself, but we also mutate our own state (`next_terminal_id`), - // so we must notify. - cx.notify(); - } - - fn add_relay_viewer_terminal( - &mut self, - relay_url: String, - room_id: String, - join_key: String, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |this, window| { - let conn = connect_relay( - &relay_url, - RelayClientToRelay::Join { - room_id: room_id.clone(), - join_key: join_key.clone(), - }, - ) - .await; - - let _ = this.update_in(window, move |this, window, cx| match conn { - Ok(conn) => { - let id = this.next_terminal_id; - this.next_terminal_id += 1; - - let tab_label: SharedString = format!("share {id}").into(); - let tab_tooltip: SharedString = - format!("Share Key {}", compose_share_key(&room_id, &join_key)).into(); - - let viewer_id = Arc::new(Mutex::new(None::)); - let controlled = Arc::new(AtomicBool::new(false)); - - let room_id_for_input = room_id.clone(); - let conn_for_input = conn.clone(); - let viewer_id_for_input = Arc::clone(&viewer_id); - let send_input: Arc = - Arc::new(move |ev| { - let Some(viewer_id) = - viewer_id_for_input.lock().ok().and_then(|v| v.clone()) - else { - return; - }; - let Ok(payload) = serde_json::to_value(ev) else { - return; - }; - conn_for_input.send(RelayClientToRelay::InputEvent { - room_id: room_id_for_input.clone(), - viewer_id, - payload, - }); - }); - - let terminal = crate::sharing::make_remote_terminal( - send_input, - Arc::clone(&controlled), - cx, - ); - let panel = this.build_wired_terminal_panel( - id, - PanelKind::Local, - tab_label, - Some(tab_tooltip), - terminal, - window, - cx, - ); - let terminal_view = panel.read(cx).terminal_view(); - - let terminal_view_id = terminal_view.entity_id(); - cx.global_mut::().viewers.insert( - terminal_view_id, - ViewerShare { - room_id, - viewer_id: Arc::clone(&viewer_id), - controlled: Arc::clone(&controlled), - conn, - }, - ); - - let relay_terminal = terminal_view.read(cx).terminal.clone(); - this.spawn_relay_pump_for_viewer(terminal_view_id, relay_terminal, window, cx); - this.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - cx.notify(); - } - Err(err) => { - notification::notify_deferred( - notification::MessageKind::Error, - format!("Join sharing failed: {err:#}"), - window, - cx, - ); - } - }); - }) - .detach(); - } - - fn add_serial_terminal_with_params( - &mut self, - backend_type: TerminalType, - params: SerialParams, - session_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - let opts = params.to_options(); - - log::debug!( - "termua: opening serial session (backend={backend_type:?}) port={} baud={}", - opts.port, - opts.baud - ); - - let builder = TerminalBuilder::new_with_pty( - backend_type, - PtySource::Serial { opts: opts.clone() }, - CursorShape::default(), - None, - ); - - let builder = match builder { - Ok(builder) => builder, - Err(err) => { - if let Some(_session_id) = session_id { - let reason = err.root_cause().to_string(); - let hint = crate::serial::open_failure_hint(¶ms.port, &err); - let message: SharedString = match hint { - Some(hint) => format!( - "Failed to open serial port `{}`.\n\nError:\n{reason}\n\n{hint}", - params.port - ) - .into(), - None => format!( - "Failed to open serial port `{}`.\n\nError:\n{reason}", - params.port - ) - .into(), - }; - - // Clicking a saved Serial session should only show a toast; editing the - // session (e.g. changing port) is done via right-click → Edit. - let message: SharedString = format!( - "{message}\n\nTip: Right-click the session and choose Edit to change the \ - port." - ) - .into(); - window.defer(cx, move |window, app| { - crate::notification::notify_app( - crate::notification::MessageKind::Error, - message, - window, - app, - ); - }); - return; - } - - let reason = err.root_cause().to_string(); - let hint = crate::serial::open_failure_hint(¶ms.port, &err); - let message = match hint { - Some(hint) => format!( - "Failed to open serial port `{}`.\n\nError:\n{reason}\n\n{hint}", - params.port - ), - None => format!( - "Failed to open serial port `{}`.\n\nError:\n{reason}", - params.port - ), - }; - - notification::notify_deferred( - notification::MessageKind::Error, - message, - window, - cx, - ); - return; - } - }; - - let panel = self.build_serial_panel_from_builder(builder, params.name, opts, window, cx); - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - cx.notify(); - } - - fn open_ssh_host_verification_dialog( - &mut self, - opts: SshOptions, - message: String, - decision_tx: smol::channel::Sender, - window: &mut Window, - cx: &mut Context, - ) { - let Some(Some(root)) = window.root::() else { - log::warn!("termua: dialog requested but window root is not gpui_component::Root"); - let _ = decision_tx.try_send(false); - return; - }; - - let target = ssh_target_label(&opts); - - root.update(cx, |root, cx| { - root.open_dialog( - move |dialog, _window, app| { - let decision_tx_ok = decision_tx.clone(); - let decision_tx_cancel = decision_tx.clone(); - - dialog - .title(Self::ssh_host_verification_dialog_title(app)) - .w(px(720.)) - .child(Self::ssh_host_verification_dialog_body(&target, &message)) - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text(t!("SshHostVerify.Button.TrustContinue").to_string()) - .cancel_text(t!("SshHostVerify.Button.Reject").to_string()), - ) - .on_ok(move |_, _window, _app| { - let _ = decision_tx_ok.try_send(true); - true - }) - .on_cancel(move |_, _window, _app| { - let _ = decision_tx_cancel.try_send(false); - true - }) - .confirm() - }, - window, - cx, - ); - }); - } - - fn ssh_host_verification_dialog_title(app: &App) -> gpui::AnyElement { - use gpui_component::ActiveTheme as _; - - h_flex() - .gap_2() - .items_center() - .child( - Icon::default() - .path(TermuaIcon::AlertCircle) - .text_color(app.theme().warning), - ) - .child(t!("SshHostVerify.Title").to_string()) - .into_any_element() - } - - fn ssh_host_verification_dialog_body(target: &str, message: &str) -> gpui::AnyElement { - v_flex() - .gap_2() - .child( - h_flex() - .gap_2() - .items_start() - .child(div().child(t!("SshHostVerify.Label.Target").to_string())) - .child( - div().min_w_0().child( - gpui_component::text::TextView::markdown( - "termua-ssh-host-verify-text-target", - target.to_string(), - ) - .selectable(true), - ), - ), - ) - .child( - h_flex() - .gap_2() - .items_start() - .child(div().child(t!("SshHostVerify.Label.Message").to_string())) - .child( - div().min_w_0().child( - gpui_component::text::TextView::markdown( - "termua-ssh-host-verify-text-message", - message.to_string(), - ) - .selectable(true), - ), - ), - ) - .child( - h_flex() - .gap_2() - .items_start() - .child(div().child(t!("SshHostVerify.Label.Note").to_string())) - .child( - div().min_w_0().child( - gpui_component::text::TextView::markdown( - "termua-ssh-host-verify-text-note", - t!("SshHostVerify.NoteText").to_string(), - ) - .selectable(true), - ), - ), - ) - .into_any_element() - } - - fn ssh_host_key_mismatch_dialog_title(app: &App) -> gpui::AnyElement { - use gpui_component::ActiveTheme as _; - - h_flex() - .gap_2() - .items_center() - .child( - Icon::default() - .path(TermuaIcon::AlertCircle) - .text_color(app.theme().danger), - ) - .child(t!("SshHostKeyMismatch.Title").to_string()) - .into_any_element() - } - - fn ssh_host_key_mismatch_markdown_row( - label_selector: &'static str, - label: String, - value_selector: &'static str, - markdown_id: &'static str, - markdown: String, - ) -> gpui::AnyElement { - h_flex() - .gap_2() - .items_start() - .child( - div() - .debug_selector(|| label_selector.to_string()) - .child(label), - ) - .child( - div() - .min_w_0() - .debug_selector(|| value_selector.to_string()) - .child( - gpui_component::text::TextView::markdown(markdown_id, markdown) - .selectable(true), - ), - ) - .into_any_element() - } - - fn ssh_host_key_mismatch_dialog_body( - target: String, - host: String, - port: u16, - reason: String, - got_fingerprint: Option, - known_hosts_label: String, - fix_cmd: Option, - ) -> gpui::AnyElement { - let mut column = v_flex() - .gap_2() - .child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-target", - t!("SshHostKeyMismatch.Label.Target").to_string(), - "termua-ssh-hostkey-mismatch-value-target", - "termua-ssh-hostkey-mismatch-text-target", - target, - )) - .child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-server", - t!("SshHostKeyMismatch.Label.Server").to_string(), - "termua-ssh-hostkey-mismatch-value-server", - "termua-ssh-hostkey-mismatch-text-server", - format!("{host}:{port}"), - )) - .child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-reason", - t!("SshHostKeyMismatch.Label.Reason").to_string(), - "termua-ssh-hostkey-mismatch-value-reason", - "termua-ssh-hostkey-mismatch-text-reason", - reason, - )); - - if let Some(fp) = got_fingerprint { - column = column.child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-fingerprint", - t!("SshHostKeyMismatch.Label.GotFingerprint").to_string(), - "termua-ssh-hostkey-mismatch-value-fingerprint", - "termua-ssh-hostkey-mismatch-text-fingerprint", - fp, - )); - } - - column = column.child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-known-hosts", - t!("SshHostKeyMismatch.Label.KnownHosts").to_string(), - "termua-ssh-hostkey-mismatch-value-known-hosts", - "termua-ssh-hostkey-mismatch-text-known-hosts", - known_hosts_label, - )); - - if let Some(cmd) = fix_cmd { - column = column.child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-manual-fix", - t!("SshHostKeyMismatch.Label.ManualFix").to_string(), - "termua-ssh-hostkey-mismatch-value-manual-fix", - "termua-ssh-hostkey-mismatch-text-manual-fix", - cmd, - )); - } - - column - .child(Self::ssh_host_key_mismatch_markdown_row( - "termua-ssh-hostkey-mismatch-label-note", - t!("SshHostKeyMismatch.Label.Note").to_string(), - "termua-ssh-hostkey-mismatch-value-note", - "termua-ssh-hostkey-mismatch-text-note", - t!("SshHostKeyMismatch.NoteText").to_string(), - )) - .into_any_element() - } - - fn close_dialog(window: &mut Window, app: &mut App) { - gpui_component::Root::update(window, app, |root, window, cx| { - root.close_dialog(window, cx); - }); - } - - fn queue_open_ssh_terminal(backend_type: TerminalType, params: SshParams, app: &mut App) { - app.global_mut::() - .pending_commands - .push(PendingCommand::OpenSshTerminal { - backend_type, - params, - }); - app.refresh_windows(); - } - - fn ssh_host_key_mismatch_dialog_footer_elements( - backend_type: TerminalType, - params: SshParams, - known_hosts_path: Option, - host: String, - port: u16, - cancel: C, - window: &mut Window, - app: &mut App, - ) -> Vec - where - C: FnOnce(&mut Window, &mut App) -> gpui::AnyElement, - { - let retry_params = params.clone(); - let retry_button = Button::new("termua-ssh-hostkey-mismatch-retry") - .label(t!("SshHostKeyMismatch.Button.Retry").to_string()) - .on_click(move |_, window, app| { - Self::close_dialog(window, app); - Self::queue_open_ssh_terminal(backend_type, retry_params.clone(), app); - }); - - let remove_params = params; - let remove_and_retry_button = Button::new("termua-ssh-hostkey-mismatch-remove-retry") - .label(t!("SshHostKeyMismatch.Button.RemoveRetry").to_string()) - .primary() - .on_click(move |_, window, app| { - let Some(path) = known_hosts_path.as_ref() else { - notification::notify_app( - notification::MessageKind::Error, - t!("SshHostKeyMismatch.Error.MissingKnownHostsPath").to_string(), - window, - app, - ); - return; - }; - - let summary = match remove_known_host_entry(path, &host, port) { - Ok(summary) => summary, - Err(err) => { - notification::notify_app( - notification::MessageKind::Error, - t!( - "SshHostKeyMismatch.Error.FailedUpdateKnownHosts", - err = format!("{err:#}") - ) - .to_string(), - window, - app, - ); - return; - } - }; - - Self::close_dialog(window, app); - - notification::notify_app( - notification::MessageKind::Info, - if summary.is_empty() { - format!("Updated {}", path.display()) - } else { - summary - }, - window, - app, - ); - - Self::queue_open_ssh_terminal(backend_type, remove_params.clone(), app); - }); - - vec![ - cancel(window, app), - retry_button.into_any_element(), - remove_and_retry_button.into_any_element(), - ] - } - - pub(crate) fn open_ssh_host_key_mismatch_dialog( - &mut self, - backend_type: TerminalType, - params: SshParams, - reason: String, - details: SshHostKeyMismatchDetails, - window: &mut Window, - cx: &mut Context, - ) { - let Some(Some(root)) = window.root::() else { - log::warn!("termua: dialog requested but window root is not gpui_component::Root"); - return; - }; - - let target = ssh_target_label(¶ms.opts); - let default_host = params.opts.host.trim().to_string(); - let default_port = params.opts.port.unwrap_or(22); - let host = details - .server_host - .clone() - .unwrap_or_else(|| default_host.clone()); - let port = details.server_port.unwrap_or(default_port); - - let known_hosts_path = details - .known_hosts_path - .clone() - .or_else(default_known_hosts_path); - - let known_hosts_label = known_hosts_path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "~/.ssh/known_hosts".to_string()); - - let fix_cmd = known_hosts_path.as_ref().map(|p| { - if port == 22 { - format!("ssh-keygen -R \"{host}\" -f \"{}\"", p.display()) - } else { - format!("ssh-keygen -R \"[{host}]:{port}\" -f \"{}\"", p.display()) - } - }); - - root.update(cx, |root, cx| { - root.open_dialog( - move |dialog, _window, app| { - let known_hosts_path_for_footer = known_hosts_path.clone(); - let host_for_footer = host.clone(); - let port_for_footer = port; - let params_for_footer = params.clone(); - - dialog - .title(Self::ssh_host_key_mismatch_dialog_title(app)) - .w(px(720.)) - .child(Self::ssh_host_key_mismatch_dialog_body( - target.clone(), - host.clone(), - port, - reason.clone(), - details.got_fingerprint.clone(), - known_hosts_label.clone(), - fix_cmd.clone(), - )) - .footer(move |_ok, cancel, window, app| { - Self::ssh_host_key_mismatch_dialog_footer_elements( - backend_type, - params_for_footer.clone(), - known_hosts_path_for_footer.clone(), - host_for_footer.clone(), - port_for_footer, - cancel, - window, - app, - ) - }) - }, - window, - cx, - ); - }); - } - - pub(crate) fn add_ssh_terminal_with_params( - &mut self, - backend_type: TerminalType, - params: SshParams, - session_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - // Building the SSH PTY involves a blocking login handshake. Run that work in a background - // thread and only attach the terminal panel on success. - let builder_fn = self.ssh_terminal_builder.clone(); - let env_for_thread = params.env.clone(); - let opts_for_thread = params.opts.clone(); - let params_for_finish = params.clone(); - let opts_for_prompt = params.opts; - let background = cx.background_executor().clone(); - - let (verify_tx, verify_rx) = - smol::channel::unbounded::(); - // Keep a sender alive on the UI thread so closing the prompt channel can't happen on the - // background thread. - let verify_tx_keepalive = verify_tx.clone(); - - // Handle host verification prompts while the background handshake is running. - cx.spawn_in(window, async move |view, window| { - while let Ok(req) = verify_rx.recv().await { - let (decision_tx, decision_rx) = smol::channel::bounded::(1); - let message = req.message; - let _ = view.update_in(window, |this, window, cx| { - this.open_ssh_host_verification_dialog( - opts_for_prompt.clone(), - message, - decision_tx.clone(), - window, - cx, - ); - }); - - let decision = decision_rx.recv().await.unwrap_or(false); - let _ = req.reply.send(decision).await; - } - }) - .detach(); - - cx.spawn_in(window, async move |view, window| { - let session_id = session_id; - let verify_tx_for_task = verify_tx.clone(); - let task = background.spawn(async move { - // Route SSH host verification prompts (unknown host keys) back to the UI thread. - // If no UI consumes them, gpui_term will time out and treat the host as untrusted. - let _guard = gpui_term::set_thread_ssh_host_verification_prompt_sender(Some( - verify_tx_for_task, - )); - (builder_fn)(backend_type, env_for_thread, opts_for_thread) - }); - - let result = task.await; - - let _ = view.update_in(window, |this, window, cx| { - this.finish_add_ssh_terminal_task( - result, - backend_type, - params_for_finish, - session_id, - window, - cx, - ); - }); - - // Keep the sender alive on the UI thread until the handshake completes. - drop(verify_tx_keepalive); - }) - .detach(); - } - - fn finish_add_ssh_terminal_task( - &mut self, - result: anyhow::Result, - backend_type: TerminalType, - params: SshParams, - session_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - match result { - Ok(builder) => { - let panel = self.build_ssh_panel_from_builder( - builder, - params.name, - params.opts, - window, - cx, - ); - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - self.clear_connecting_session(session_id, cx); - cx.notify(); - } - Err(err) => { - let root_reason = err.root_cause().to_string(); - if let Some(details) = parse_ssh_host_key_mismatch(&root_reason) { - self.open_ssh_host_key_mismatch_dialog( - backend_type, - params, - root_reason, - details, - window, - cx, - ); - self.clear_connecting_session(session_id, cx); - return; - } - let id = self.next_terminal_id; - self.next_terminal_id += 1; - - let tab_label = - dedupe_tab_label(&mut self.ssh_tab_label_counts, params.name.as_str()); - let tab_tooltip = ssh_tab_tooltip(¶ms.opts); - let message = ssh_connect_failure_message(¶ms.opts, &err); - - let panel = cx.new(|cx| { - SshErrorPanel::new(id, tab_label, Some(tab_tooltip), message.into(), cx) - }); - - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); - self.clear_connecting_session(session_id, cx); - cx.notify(); - } - } - } - - fn clear_connecting_session(&mut self, session_id: Option, cx: &mut Context) { - let Some(session_id) = session_id else { - return; - }; - self.sessions_sidebar.update(cx, |sidebar, cx| { - sidebar.set_connecting(session_id, false, cx); - }); - } - - pub(super) fn open_session_by_id( - &mut self, - id: i64, - window: &mut Window, - cx: &mut Context, - ) { - let Ok(Some(session)) = crate::store::load_session(id) else { - return; - }; - - let backend_type = match session.backend { - crate::settings::TerminalBackend::Alacritty => TerminalType::Alacritty, - crate::settings::TerminalBackend::Wezterm => TerminalType::WezTerm, - }; - - let protocol = session.protocol.clone(); - match protocol { - crate::store::SessionType::Local => { - self.open_saved_local_session(backend_type, session, window, cx); - } - crate::store::SessionType::Ssh => { - self.open_saved_ssh_session(backend_type, session, id, window, cx); - } - crate::store::SessionType::Serial => { - self.open_saved_serial_session(backend_type, session, id, window, cx); - } - } - } - - fn open_saved_local_session( - &mut self, - backend_type: TerminalType, - session: crate::store::Session, - window: &mut Window, - cx: &mut Context, - ) { - let shell_program = session.shell_program.clone().unwrap_or_default(); - let session_env = session.env.clone().unwrap_or_default(); - let env = build_terminal_env( - shell_program.as_str(), - session.term(), - session.colorterm(), - session.charset(), - &session_env, - ); - self.add_local_terminal_with_params(backend_type, env, window, cx); - } - - fn open_saved_ssh_session( - &mut self, - backend_type: TerminalType, - session: crate::store::Session, - session_id: i64, - window: &mut Window, - cx: &mut Context, - ) { - let Some(host) = session.ssh_host.as_deref() else { - return; - }; - let port = session.ssh_port.unwrap_or(22); - - let session_env = session.env.clone().unwrap_or_default(); - let env = build_terminal_env( - "", - session.term(), - session.colorterm(), - session.charset(), - &session_env, - ); - let proxy = ssh_proxy_from_session(&session); - let name = session.label; - - let auth = match session.ssh_auth_type { - Some(crate::store::SshAuthType::Config) => Authentication::Config, - Some(crate::store::SshAuthType::Password) => { - let user = session.ssh_user.unwrap_or_else(|| "root".to_string()); - let password = session.ssh_password.unwrap_or_default(); - if password.trim().is_empty() { - notification::notify_deferred( - notification::MessageKind::Error, - "Missing saved SSH password for this session.", - window, - cx, - ); - return; - } - Authentication::Password(user, password) - } - None => { - // Back-compat: default to config auth if not recorded. - Authentication::Config - } - }; - - let opts = SshOptions { - host: host.to_string(), - port: Some(port), - auth, - proxy, - backend: cx - .try_global::() - .map(|pref| pref.backend) - .unwrap_or_default(), - tcp_nodelay: session.ssh_tcp_nodelay, - tcp_keepalive: session.ssh_tcp_keepalive, - }; - self.add_ssh_terminal_with_params( - backend_type, - SshParams { env, name, opts }, - Some(session_id), - window, - cx, - ); - } - - fn open_saved_serial_session( - &mut self, - backend_type: TerminalType, - session: crate::store::Session, - session_id: i64, - window: &mut Window, - cx: &mut Context, - ) { - let Some(port) = session.serial_port.clone() else { - notification::notify_deferred( - notification::MessageKind::Error, - "Missing saved serial port for this session.", - window, - cx, - ); - return; - }; - - let baud = session.serial_baud.unwrap_or(9600); - let data_bits = session.serial_data_bits.unwrap_or(8); - let parity = session - .serial_parity - .unwrap_or(crate::store::SerialParity::None); - let stop_bits = session - .serial_stop_bits - .unwrap_or(crate::store::SerialStopBits::One); - let flow_control = session - .serial_flow_control - .unwrap_or(crate::store::SerialFlowControl::None); - - self.add_serial_terminal_with_params( - backend_type, - SerialParams { - name: session.label, - port, - baud, - data_bits, - parity, - stop_bits, - flow_control, - }, - Some(session_id), - window, - cx, - ); - } - - fn reload_sessions_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - let sidebar = self.sessions_sidebar.clone(); - sidebar.update(cx, |sidebar, cx| { - sidebar.reload(window, cx); - }); - } - - fn register_terminal_target_and_focus( - &mut self, - id: usize, - tab_label: SharedString, - terminal_view: &gpui::Entity, - terminal_weak: gpui::WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - crate::assistant::register_terminal_target(cx, id, tab_label, terminal_weak.clone()); - - let focused_terminal_view = terminal_view.downgrade(); - let focused_terminal = terminal_weak; - let focus_handle = terminal_view.read(cx).focus_handle.clone(); - let sub = cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { - this.focused_terminal_view = Some(focused_terminal_view.clone()); - crate::assistant::set_focused_terminal(cx, Some(id), Some(focused_terminal.clone())); - }); - self._subscriptions.push(sub); - } - - pub(crate) fn subscribe_terminal_view_events( - &mut self, - terminal_view: &gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) { - let source_terminal_view = terminal_view.clone(); - let source_terminal_view_for_cb = source_terminal_view.clone(); - let subscription = cx.subscribe_in( - &source_terminal_view, - window, - 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, - detail, - } => { - let kind = match level { - gpui::PromptLevel::Info => crate::notification::MessageKind::Info, - gpui::PromptLevel::Warning => crate::notification::MessageKind::Warning, - gpui::PromptLevel::Critical => crate::notification::MessageKind::Error, - }; - let message = match detail.as_deref() { - Some(detail) if !detail.trim().is_empty() => format!("{title}\n{detail}"), - _ => title.clone(), - }; - crate::notification::record(kind, message, cx); - } - _ => {} - }, - ); - self._subscriptions.push(subscription); - } - - fn create_terminal_view( - &self, - kind: PanelKind, - terminal: gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) -> gpui::Entity { - if kind == PanelKind::Recorder { - return cx.new(|cx| TerminalView::new_with_context_menu(terminal, window, cx, false)); - } - - let provider = self.terminal_context_menu_provider.clone(); - cx.new(|cx| { - TerminalView::new_with_context_menu_provider(terminal, window, cx, true, Some(provider)) - }) - } - - fn build_wired_terminal_panel( - &mut self, - id: usize, - kind: PanelKind, - tab_label: SharedString, - tab_tooltip: Option, - terminal: gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) -> gpui::Entity { - self.subscribe_terminal_events_for_messages( - terminal.clone(), - id, - tab_label.clone(), - window, - cx, - ); - - let terminal_weak = terminal.downgrade(); - let terminal_view = self.create_terminal_view(kind, terminal, window, cx); - - self.register_terminal_target_and_focus( - id, - tab_label.clone(), - &terminal_view, - terminal_weak, - window, - cx, - ); - self.subscribe_terminal_view_events(&terminal_view, window, cx); - - let focus: FocusHandle = terminal_view.read(cx).focus_handle.clone(); - window.focus(&focus, cx); - - cx.new(|_| TerminalPanel::new(id, kind, tab_label, tab_tooltip, terminal_view)) - } - - fn build_ssh_panel_from_builder( - &mut self, - builder: TerminalBuilder, - name: String, - opts: SshOptions, - window: &mut Window, - cx: &mut Context, - ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; - - let tab_label = dedupe_tab_label(&mut self.ssh_tab_label_counts, name.as_str()); - let tab_tooltip = ssh_tab_tooltip(&opts); - - let terminal = cx.new(move |cx| builder.subscribe(cx)); - self.build_wired_terminal_panel( - id, - PanelKind::Ssh, - tab_label, - Some(tab_tooltip), - terminal, - window, - cx, - ) - } - - fn build_serial_panel_from_builder( - &mut self, - builder: TerminalBuilder, - name: String, - opts: SerialOptions, - window: &mut Window, - cx: &mut Context, - ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; - - let tab_label = terminal_panel_tab_name(PanelKind::Serial, id); - let tab_tooltip: SharedString = format!("{name}\n{} @ {}", opts.port, opts.baud).into(); - - let terminal = cx.new(move |cx| builder.subscribe(cx)); - self.build_wired_terminal_panel( - id, - PanelKind::Serial, - tab_label, - Some(tab_tooltip), - terminal, - window, - cx, - ) - } - - fn build_terminal_panel( - &mut self, - kind: PanelKind, - backend_type: TerminalType, - env: HashMap, - window: &mut Window, - cx: &mut Context, - ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; - - let env = match kind { - PanelKind::Local => crate::shell_integration::maybe_inject_local_shell_osc133(env, id), - PanelKind::Ssh | PanelKind::Serial | PanelKind::Recorder => env, - }; - - let tab_label = match kind { - PanelKind::Local => crate::panel::local_terminal_panel_tab_name( - &env, - id, - &mut self.local_tab_label_counts, - ), - PanelKind::Ssh | PanelKind::Serial | PanelKind::Recorder => { - terminal_panel_tab_name(kind, id) - } - }; - - let terminal = cx.new(|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) - } - - fn open_sftp_for_terminal_view( - &mut self, - terminal_view: gpui::Entity, - window: &mut Window, - cx: &mut Context, - ) { - let mut tab_label: gpui::SharedString = "SFTP".into(); - let tab_panels = self.dock_area.read(cx).visible_tab_panels(cx); - for tab_panel in tab_panels { - let Some(active_panel) = tab_panel.read(cx).active_panel(cx) else { - continue; - }; - - let Ok(terminal_panel) = active_panel.view().downcast::() else { - continue; - }; - - let terminal_panel = terminal_panel.read(cx); - if terminal_panel.terminal_view().entity_id() == terminal_view.entity_id() { - tab_label = terminal_panel.tab_label(); - break; - } - } - - let panel = match crate::panel::sftp_panel::SftpDockPanel::open_for_terminal_view( - terminal_view, - tab_label, - window, - cx, - ) { - Ok(panel) => panel, - Err(err) => { - notification::notify_deferred( - notification::MessageKind::Error, - err.to_string(), - window, - cx, - ); - return; - } - }; - - self.dock_area.update(cx, |dock, cx| { - dock.add_panel(panel, DockPlacement::Bottom, None, window, cx); - if !dock.is_dock_open(DockPlacement::Bottom, cx) { - dock.toggle_dock(DockPlacement::Bottom, window, cx); - } - }); - 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, - input: TerminalUserInput, - cx: &mut Context, - ) { - cx.global::().report_activity(); - - if cx.global::().locked() { - return; - } - - if !cx.global::().multi_exec_enabled { - return; - } - - // Only broadcast to panes that are currently visible: the active tab in each visible - // TabPanel (splits). This intentionally skips background tabs. - let tab_panels = self.dock_area.read(cx).visible_tab_panels(cx); - for tab_panel in tab_panels { - let Some(active_panel) = tab_panel.read(cx).active_panel(cx) else { - continue; - }; - - let Ok(terminal_panel) = active_panel.view().downcast::() else { - continue; - }; - - let target_terminal_view = terminal_panel.read(cx).terminal_view(); - if target_terminal_view.entity_id() == source.entity_id() { - continue; - } - - match &input { - TerminalUserInput::Keystroke(keystroke) => { - let keystroke = keystroke.clone(); - target_terminal_view.update(cx, |view, cx| { - view.terminal.update(cx, |term, cx| { - term.try_keystroke( - &keystroke, - TerminalSettings::global(cx).option_as_meta, - ); - }); - }); - } - TerminalUserInput::Text(text) => { - let bytes = text.clone().into_bytes(); - target_terminal_view.update(cx, |view, cx| { - view.terminal.update(cx, |term, _| { - term.input(bytes.clone()); - }); - }); - } - TerminalUserInput::Paste(text) => { - let text = text.clone(); - target_terminal_view.update(cx, |view, cx| { - view.terminal.update(cx, |term, _| { - term.paste(&text); - }); - }); - } - } - } - } } diff --git a/termua/src/window/main_window/actions/sftp.rs b/termua/src/window/main_window/actions/sftp.rs new file mode 100644 index 0000000..a15a824 --- /dev/null +++ b/termua/src/window/main_window/actions/sftp.rs @@ -0,0 +1,505 @@ +use std::sync::Arc; + +use gpui::{App, Context, SharedString, Window}; +use gpui_common::format_bytes; +use gpui_dock::PanelView; +use gpui_term::Event as TerminalEvent; +use gpui_transfer::{ + AUTO_DISMISS_AFTER, TransferCenterState, TransferKind, TransferProgress, TransferStatus, + TransferTask, +}; +use smol::Timer; + +use super::TermuaWindow; +use crate::{ + notification, + panel::{PanelKind, TerminalPanel}, + sharing::compose_share_key, +}; + +impl TermuaWindow { + fn sftp_upload_panel_prefix(panel_id: usize) -> String { + format!("sftp-upload-{panel_id}-") + } + + fn sftp_upload_group_id(panel_id: usize, transfer_id: u64) -> String { + format!("sftp-upload-{panel_id}-{transfer_id}") + } + + fn sftp_upload_task_id(panel_id: usize, transfer_id: u64, file_index: usize) -> String { + format!( + "{}-{file_index}", + Self::sftp_upload_group_id(panel_id, transfer_id) + ) + } + + pub(crate) fn subscribe_terminal_events_for_messages( + &mut self, + terminal: gpui::Entity, + panel_id: usize, + tab_label: gpui::SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let sub = cx.subscribe_in( + &terminal, + window, + 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, + window: &mut Window, + cx: &mut Context, + ) { + if Self::handle_terminal_event_toast(event, window, cx) { + return; + } + 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( + event: &TerminalEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match event { + TerminalEvent::Toast { + level, + title, + detail, + } => { + let kind = match level { + gpui::PromptLevel::Info => crate::notification::MessageKind::Info, + gpui::PromptLevel::Warning => crate::notification::MessageKind::Warning, + gpui::PromptLevel::Critical => crate::notification::MessageKind::Error, + }; + let message = match detail.as_deref() { + Some(detail) if !detail.trim().is_empty() => format!("{title}\n{detail}"), + _ => title.clone(), + }; + crate::notification::notify(kind, message, window, cx); + true + } + _ => false, + } + } + + fn upsert_transfer(task: TransferTask, cx: &mut Context) { + if cx.try_global::().is_none() { + return; + } + cx.global_mut::().upsert(task); + } + + fn remove_transfer(id: &str, cx: &mut Context) { + if cx.try_global::().is_none() { + return; + } + cx.global_mut::().remove(id); + } + + fn sftp_upload_progress(sent: u64, total: u64) -> TransferProgress { + if total > 0 { + TransferProgress::Determinate((sent as f32 / total as f32).clamp(0.0, 1.0)) + } else { + TransferProgress::Determinate(1.0) + } + } + + fn build_sftp_upload_task( + panel_id: usize, + transfer_id: u64, + file_index: usize, + file: &str, + status: TransferStatus, + sent: u64, + total: u64, + cancel: Option<&Arc>, + ) -> (String, TransferTask) { + let group_id = Self::sftp_upload_group_id(panel_id, transfer_id); + let id = Self::sftp_upload_task_id(panel_id, transfer_id, file_index); + let task = TransferTask::new(id.clone(), SharedString::from(file.to_string())) + .with_group(group_id, Some(file_index.saturating_add(1))) + .with_kind(TransferKind::Upload) + .with_status(status) + .with_progress(Self::sftp_upload_progress(sent, total)) + .with_bytes(Some(sent), Some(total).filter(|t| *t > 0)); + + let task = if let Some(cancel) = cancel { + task.with_cancel_token(Arc::clone(cancel)) + } else { + task + }; + + (id, task) + } + + pub(super) 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)) + }) + } + + pub(super) 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; + } + + match terminal_panel.kind() { + PanelKind::Recorder => false, + PanelKind::Ssh => !terminal_panel + .terminal_view() + .read(cx) + .terminal + .read(cx) + .has_exited(), + PanelKind::Local | PanelKind::Serial => true, + } + }) else { + return; + }; + + self.close_terminal_panel(panel, window, cx); + } + + fn handle_terminal_event_sftp_upload( + panel_id: usize, + tab_label: &SharedString, + event: &TerminalEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match event { + TerminalEvent::SftpUploadFileProgress { + transfer_id, + file_index, + file, + sent, + total, + cancel, + } => { + Self::handle_sftp_upload_file_progress( + panel_id, + transfer_id, + file_index, + file, + sent, + total, + cancel, + cx, + ); + true + } + TerminalEvent::SftpUploadFinished { files, total_bytes } => { + Self::handle_sftp_upload_finished(tab_label, files.len(), *total_bytes, window, cx); + true + } + TerminalEvent::SftpUploadFileFinished { + transfer_id, + file_index, + file, + bytes, + } => { + Self::handle_sftp_upload_file_finished( + panel_id, + transfer_id, + file_index, + file, + *bytes, + cx, + ); + true + } + TerminalEvent::SftpUploadCancelled => { + Self::handle_sftp_upload_cancelled(panel_id, tab_label, window, cx); + true + } + TerminalEvent::SftpUploadFileCancelled { + transfer_id, + file_index, + file, + sent, + total, + } => { + Self::handle_sftp_upload_file_cancelled( + panel_id, + transfer_id, + file_index, + file, + sent, + total, + cx, + ); + true + } + _ => false, + } + } + + fn handle_sftp_upload_file_progress( + panel_id: usize, + transfer_id: &u64, + file_index: &usize, + file: &str, + sent: &u64, + total: &u64, + cancel: &Arc, + cx: &mut Context, + ) { + let (_id, task) = Self::build_sftp_upload_task( + panel_id, + *transfer_id, + *file_index, + file, + TransferStatus::InProgress, + *sent, + *total, + Some(cancel), + ); + Self::upsert_transfer(task, cx); + } + + fn handle_sftp_upload_finished( + tab_label: &SharedString, + file_count: usize, + total_bytes: u64, + window: &mut Window, + cx: &mut Context, + ) { + notification::notify( + notification::MessageKind::Success, + Self::sftp_upload_finished_message(tab_label, file_count, total_bytes), + window, + cx, + ); + } + + fn handle_sftp_upload_file_finished( + panel_id: usize, + transfer_id: &u64, + file_index: &usize, + file: &str, + bytes: u64, + cx: &mut Context, + ) { + let (id, task) = Self::build_sftp_upload_task( + panel_id, + *transfer_id, + *file_index, + file, + TransferStatus::Finished, + bytes, + bytes, + None, + ); + Self::upsert_transfer(task, cx); + Self::schedule_transfer_auto_dismiss(id, cx); + } + + fn handle_sftp_upload_cancelled( + panel_id: usize, + tab_label: &SharedString, + window: &mut Window, + cx: &mut Context, + ) { + notification::notify( + notification::MessageKind::Warning, + Self::sftp_upload_cancelled_message(tab_label), + window, + cx, + ); + + if cx.try_global::().is_some() { + cx.global_mut::() + .remove_groups_with_prefix(Self::sftp_upload_panel_prefix(panel_id).as_str()); + } + } + + fn sftp_upload_count_label(file_count: usize) -> String { + match file_count { + 1 => "1 file".to_string(), + n => format!("{n} files"), + } + } + + fn sftp_upload_finished_message( + tab_label: &SharedString, + file_count: usize, + total_bytes: u64, + ) -> String { + format!( + "[{}] Upload via SFTP complete: {}, {}", + tab_label.as_ref(), + Self::sftp_upload_count_label(file_count), + format_bytes(total_bytes) + ) + } + + fn sftp_upload_cancelled_message(tab_label: &SharedString) -> String { + format!("[{}] Upload via SFTP cancelled", tab_label.as_ref()) + } + + pub(in crate::window::main_window::actions) fn sharing_started_message( + room_id: &str, + join_key: &str, + ) -> String { + format!( + "Sharing started\nShare Key: {}", + compose_share_key(room_id, join_key) + ) + } + + fn handle_sftp_upload_file_cancelled( + panel_id: usize, + transfer_id: &u64, + file_index: &usize, + file: &str, + sent: &u64, + total: &u64, + cx: &mut Context, + ) { + let (id, task) = Self::build_sftp_upload_task( + panel_id, + *transfer_id, + *file_index, + file, + TransferStatus::Cancelled, + *sent, + *total, + None, + ); + Self::upsert_transfer(task, cx); + Self::schedule_transfer_auto_dismiss(id, cx); + } + + fn schedule_transfer_auto_dismiss(id: String, cx: &mut Context) { + cx.spawn(async move |this, cx| { + Timer::after(AUTO_DISMISS_AFTER).await; + let _ = this.update(cx, |_this, cx| { + Self::remove_transfer(id.as_str(), cx); + cx.notify(); + }); + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, atomic::AtomicBool}; + + use gpui_transfer::TransferStatus; + + use super::TermuaWindow; + + #[test] + fn sftp_transfer_keys_are_stable() { + assert_eq!(TermuaWindow::sftp_upload_panel_prefix(7), "sftp-upload-7-"); + assert_eq!(TermuaWindow::sftp_upload_group_id(7, 9), "sftp-upload-7-9"); + assert_eq!( + TermuaWindow::sftp_upload_task_id(7, 9, 2), + "sftp-upload-7-9-2" + ); + } + + #[test] + fn sftp_upload_finished_message_uses_consistent_copy() { + let label = gpui::SharedString::from("ssh 1"); + assert_eq!( + TermuaWindow::sftp_upload_finished_message(&label, 1, 1024), + "[ssh 1] Upload via SFTP complete: 1 file, 1.0 KiB" + ); + assert_eq!( + TermuaWindow::sftp_upload_finished_message(&label, 2, 2048), + "[ssh 1] Upload via SFTP complete: 2 files, 2.0 KiB" + ); + } + + #[test] + fn sftp_upload_cancelled_message_uses_consistent_copy() { + let label = gpui::SharedString::from("ssh 1"); + assert_eq!( + TermuaWindow::sftp_upload_cancelled_message(&label), + "[ssh 1] Upload via SFTP cancelled" + ); + } + + #[test] + fn sharing_started_message_uses_share_key_copy() { + assert_eq!( + TermuaWindow::sharing_started_message("AbC234xYz", "k3Y9a2"), + "Sharing started\nShare Key: AbC234xYz-k3Y9a2" + ); + } + + #[test] + fn sftp_upload_task_builder_normalizes_progress_and_keys() { + let cancel = Arc::new(AtomicBool::new(false)); + let (id, task) = TermuaWindow::build_sftp_upload_task( + 7, + 9, + 2, + "foo.txt", + TransferStatus::InProgress, + 12, + 0, + Some(&cancel), + ); + + assert_eq!(id, "sftp-upload-7-9-2"); + assert_eq!(task.group_id.as_deref(), Some("sftp-upload-7-9")); + assert_eq!(task.group_total, Some(3)); + assert_eq!(task.status, TransferStatus::InProgress); + assert_eq!(task.bytes_done, Some(12)); + assert_eq!(task.bytes_total, None); + assert_eq!( + task.progress, + gpui_transfer::TransferProgress::Determinate(1.0) + ); + assert!(task.cancel.is_some()); + } +} diff --git a/termua/src/window/main_window/actions/sharing.rs b/termua/src/window/main_window/actions/sharing.rs new file mode 100644 index 0000000..eb9ac89 --- /dev/null +++ b/termua/src/window/main_window/actions/sharing.rs @@ -0,0 +1,1156 @@ +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use gpui::{AppContext, Context, Focusable, ParentElement, ReadGlobal, Styled, Window, px}; +use gpui_component::v_flex; +use gpui_term::{ + Event as TerminalEvent, RemoteBackendEvent, TerminalSettings, TerminalView, + remote::{RemoteFrame, RemoteInputEvent, RemoteSnapshot, RemoteTerminalContent}, +}; +use smol::Timer; + +use super::TermuaWindow; +use crate::{ + PendingCommand, TermuaAppState, lock_screen, notification, + sharing::{ + ClientToRelay as RelayClientToRelay, HostShare, RelaySharingState, + RelayToClient as RelayRelayToClient, ReleaseControl, RequestControl, RevokeControl, + StartSharing, StopSharing, connect_relay, gen_join_key, gen_room_id, parse_share_key, + }, +}; + +#[derive(thiserror::Error, Debug, Eq, PartialEq)] +enum JoinSharingInputError { + #[error("Relay URL / Share Key cannot be empty.")] + EmptyFields, + #[error("Relay URL must start with ws:// or wss://")] + InvalidRelayUrl, + #[error("Invalid Share Key: {0}")] + InvalidShareKey(String), +} + +fn build_join_sharing_pending_command( + relay_url: &str, + share_key: &str, +) -> Result { + let relay_url = relay_url.trim().to_string(); + let share_key = share_key.trim(); + + if relay_url.is_empty() || share_key.is_empty() { + return Err(JoinSharingInputError::EmptyFields); + } + if !relay_url.starts_with("ws://") && !relay_url.starts_with("wss://") { + return Err(JoinSharingInputError::InvalidRelayUrl); + } + + let (room_id, join_key) = parse_share_key(share_key) + .map_err(|err| JoinSharingInputError::InvalidShareKey(err.to_string()))?; + if room_id.is_empty() || join_key.is_empty() { + return Err(JoinSharingInputError::InvalidShareKey( + share_key.to_string(), + )); + } + + Ok(PendingCommand::JoinRelaySharing { + relay_url, + room_id, + join_key, + }) +} + +impl TermuaWindow { + pub(in crate::window::main_window) fn on_start_sharing( + &mut self, + _: &StartSharing, + window: &mut Window, + cx: &mut Context, + ) { + if cx.global::().locked() { + return; + } + if !crate::sharing::sharing_feature_enabled(cx) { + notification::notify_deferred( + notification::MessageKind::Warning, + "Sharing is disabled in Settings.", + window, + cx, + ); + return; + } + let Some(focused) = self + .focused_terminal_view + .as_ref() + .and_then(|v| v.upgrade()) + else { + notification::notify_deferred( + notification::MessageKind::Error, + "No active terminal to share.", + window, + cx, + ); + return; + }; + + let terminal_view_id = focused.entity_id(); + if cx + .global::() + .hosts + .contains_key(&terminal_view_id) + { + return; + } + + let relay_url = crate::sharing::effective_relay_url(cx); + let room_id = gen_room_id(); + let join_key = gen_join_key(); + cx.spawn_in(window, async move |this, window| { + let conn = connect_relay( + &relay_url, + RelayClientToRelay::Register { + room_id: room_id.clone(), + join_key: join_key.clone(), + ttl_secs: Some(30 * 60), + }, + ) + .await; + + let _ = this.update_in(window, move |this, window, cx| match conn { + Ok(conn) => { + let dirty = Arc::new(AtomicBool::new(false)); + let selection_dirty = Arc::new(AtomicBool::new(false)); + + cx.global_mut::().hosts.insert( + terminal_view_id, + HostShare { + room_id: room_id.clone(), + controller_id: None, + pending_request: false, + conn: conn.clone(), + seq: 0, + dirty: dirty.clone(), + selection_dirty: selection_dirty.clone(), + }, + ); + + let terminal = focused.read(cx).terminal.clone(); + this.subscribe_host_terminal_for_sharing_frames( + terminal, + dirty.clone(), + selection_dirty.clone(), + window, + cx, + ); + + this.send_host_snapshot(&focused, cx); + this.spawn_relay_pump_for_host(terminal_view_id, focused.clone(), window, cx); + this.spawn_relay_publisher_for_host( + terminal_view_id, + focused.clone(), + conn, + dirty, + selection_dirty, + window, + cx, + ); + + notification::notify_deferred( + notification::MessageKind::Info, + Self::sharing_started_message(&room_id, &join_key), + window, + cx, + ); + } + Err(err) => { + notification::notify_deferred( + notification::MessageKind::Error, + format!("Start sharing failed: {err:#}"), + window, + cx, + ); + } + }); + }) + .detach(); + } + + pub(in crate::window::main_window) fn on_stop_sharing( + &mut self, + _: &StopSharing, + window: &mut Window, + cx: &mut Context, + ) { + if cx.global::().locked() { + return; + } + let Some(focused) = self + .focused_terminal_view + .as_ref() + .and_then(|v| v.upgrade()) + else { + return; + }; + let terminal_view_id = focused.entity_id(); + + let host = cx + .global_mut::() + .hosts + .remove(&terminal_view_id); + if let Some(host) = host { + host.conn.send(RelayClientToRelay::Stop { + room_id: host.room_id.clone(), + }); + host.conn.close(); + notification::notify_deferred( + notification::MessageKind::Info, + "Stopped sharing.", + window, + cx, + ); + } + } + + pub(in crate::window::main_window) fn on_request_control( + &mut self, + _: &RequestControl, + window: &mut Window, + cx: &mut Context, + ) { + if cx.global::().locked() { + return; + } + let Some(focused) = self + .focused_terminal_view + .as_ref() + .and_then(|v| v.upgrade()) + else { + return; + }; + let terminal_view_id = focused.entity_id(); + let Some(viewer) = cx + .global::() + .viewers + .get(&terminal_view_id) + .cloned() + else { + return; + }; + let Some(viewer_id) = viewer.viewer_id.lock().ok().and_then(|v| v.clone()) else { + notification::notify_deferred( + notification::MessageKind::Warning, + "Not joined yet.", + window, + cx, + ); + return; + }; + viewer.conn.send(RelayClientToRelay::Request { + room_id: viewer.room_id.clone(), + viewer_id, + viewer_label: None, + }); + } + + pub(in crate::window::main_window) fn on_release_control( + &mut self, + _: &ReleaseControl, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(focused) = self + .focused_terminal_view + .as_ref() + .and_then(|v| v.upgrade()) + else { + return; + }; + let terminal_view_id = focused.entity_id(); + let Some(viewer) = cx + .global::() + .viewers + .get(&terminal_view_id) + .cloned() + else { + return; + }; + let Some(viewer_id) = viewer.viewer_id.lock().ok().and_then(|v| v.clone()) else { + return; + }; + viewer.conn.send(RelayClientToRelay::Release { + room_id: viewer.room_id.clone(), + viewer_id, + }); + } + + pub(in crate::window::main_window) fn on_revoke_control( + &mut self, + _: &RevokeControl, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(focused) = self + .focused_terminal_view + .as_ref() + .and_then(|v| v.upgrade()) + else { + return; + }; + let terminal_view_id = focused.entity_id(); + let Some(host) = cx + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + else { + return; + }; + // Ensure local host state does not keep denying new requests as "busy". + crate::sharing::clear_host_control_state(host); + host.conn.send(RelayClientToRelay::Revoked { + room_id: host.room_id.clone(), + }); + } + + fn send_host_snapshot( + &mut self, + terminal_view: &gpui::Entity, + cx: &mut Context, + ) { + let terminal_view_id = terminal_view.entity_id(); + let terminal = terminal_view.read(cx).terminal.clone(); + let term_read = terminal.read(cx); + let viewport_line_numbers = Self::host_viewport_line_numbers(&term_read); + let payload = gpui_term::remote::RemoteTerminalContent::from_local( + term_read.last_content(), + term_read.total_lines(), + term_read.viewport_lines(), + viewport_line_numbers, + ); + + let Ok(payload_json) = serde_json::to_value(payload) else { + return; + }; + + let (room_id, seq, conn) = { + let Some(host) = cx + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + else { + return; + }; + host.seq = host.seq.wrapping_add(1); + (host.room_id.clone(), host.seq, host.conn.clone()) + }; + + conn.send(RelayClientToRelay::Snapshot { + room_id, + seq, + payload: payload_json, + }); + } + + fn send_host_frame( + &mut self, + terminal_view: &gpui::Entity, + cx: &mut Context, + ) { + let terminal_view_id = terminal_view.entity_id(); + let terminal = terminal_view.read(cx).terminal.clone(); + let term_read = terminal.read(cx); + let viewport_line_numbers = Self::host_viewport_line_numbers(&term_read); + let payload = gpui_term::remote::RemoteTerminalContent::from_local( + term_read.last_content(), + term_read.total_lines(), + term_read.viewport_lines(), + viewport_line_numbers, + ); + + let Ok(payload_json) = serde_json::to_value(payload) else { + return; + }; + + let (room_id, seq, conn) = { + let Some(host) = cx + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + else { + return; + }; + host.seq = host.seq.wrapping_add(1); + (host.room_id.clone(), host.seq, host.conn.clone()) + }; + + conn.send(RelayClientToRelay::Frame { + room_id, + seq, + payload: payload_json, + }); + } + + fn host_viewport_line_numbers(terminal: &gpui_term::Terminal) -> Vec> { + let total_lines = terminal.total_lines(); + let viewport_lines = terminal.viewport_lines().max(1); + let display_offset = terminal.last_content().display_offset; + let viewport_top = total_lines + .saturating_sub(viewport_lines) + .saturating_sub(display_offset); + let rows = terminal.last_content().terminal_bounds.num_lines().max(1); + terminal.logical_line_numbers_from_top(viewport_top, rows) + } + + fn send_host_selection_update( + &mut self, + terminal_view: &gpui::Entity, + cx: &mut Context, + ) { + let terminal_view_id = terminal_view.entity_id(); + let terminal = terminal_view.read(cx).terminal.clone(); + let term_read = terminal.read(cx); + let payload = + gpui_term::remote::RemoteSelectionUpdate::from_local(term_read.last_content()); + + let Ok(payload_json) = serde_json::to_value(payload) else { + return; + }; + + let (room_id, seq, conn) = { + let Some(host) = cx + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + else { + return; + }; + host.seq = host.seq.wrapping_add(1); + (host.room_id.clone(), host.seq, host.conn.clone()) + }; + + conn.send(RelayClientToRelay::Selection { + room_id, + seq, + payload: payload_json, + }); + } + + fn spawn_relay_pump_for_host( + &mut self, + terminal_view_id: gpui::EntityId, + terminal_view: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = cx + .global::() + .hosts + .get(&terminal_view_id) + .cloned() + else { + return; + }; + + let host_conn = host.conn; + cx.spawn_in(window, async move |this, window| { + while let Some(msg) = host_conn.recv().await { + let _ = this.update_in(window, |this, window, cx| { + this.handle_host_relay_message( + terminal_view_id, + &terminal_view, + msg, + window, + cx, + ); + cx.notify(); + }); + } + }) + .detach(); + } + + pub(super) fn spawn_relay_pump_for_viewer( + &mut self, + terminal_view_id: gpui::EntityId, + terminal: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(viewer) = cx + .global::() + .viewers + .get(&terminal_view_id) + .cloned() + else { + return; + }; + + let viewer_conn = viewer.conn; + let controlled = viewer.controlled; + let viewer_id = viewer.viewer_id; + + cx.spawn_in(window, async move |this, window| { + while let Some(msg) = viewer_conn.recv().await { + let should_close = matches!(msg, RelayRelayToClient::Error { .. }); + let _ = this.update_in(window, |this, window, cx| { + this.handle_viewer_relay_message( + terminal_view_id, + &terminal, + &viewer_id, + &controlled, + msg, + window, + cx, + ); + cx.notify(); + }); + if should_close { + viewer_conn.close(); + break; + } + } + }) + .detach(); + } + + fn spawn_relay_publisher_for_host( + &mut self, + terminal_view_id: gpui::EntityId, + terminal_view: gpui::Entity, + conn: crate::sharing::RelayConn, + dirty: Arc, + selection_dirty: Arc, + window: &mut Window, + cx: &mut Context, + ) { + cx.spawn_in(window, async move |this, window| { + let mut tick: u32 = 0; + let mut last_frame_at = Instant::now() + .checked_sub(Duration::from_secs(3600)) + .unwrap_or_else(Instant::now); + let mut last_selection_at = last_frame_at; + let mut last_display_offset: Option = None; + loop { + Timer::after(Duration::from_millis(20)).await; + tick = tick.wrapping_add(1); + + let keep_running = this + .update_in(window, |this, _window, cx| { + if !cx + .global::() + .hosts + .contains_key(&terminal_view_id) + { + return false; + } + + if tick.is_multiple_of(50) { + conn.send(RelayClientToRelay::Ping); + } + + // Host-initiated scroll does not emit `TerminalEvent::Wakeup`, so detect it + // by observing `display_offset` and mark frames dirty. + let display_offset = terminal_view + .read(cx) + .terminal + .read(cx) + .last_content() + .display_offset; + match last_display_offset { + Some(prev) if prev != display_offset => { + last_display_offset = Some(display_offset); + dirty.store(true, Ordering::Relaxed); + } + None => { + last_display_offset = Some(display_offset); + } + _ => {} + } + + // Only send frames after terminal changes, with a conservative rate cap. + if dirty.load(Ordering::Relaxed) + && last_frame_at.elapsed() >= Duration::from_millis(50) + { + dirty.store(false, Ordering::Relaxed); + last_frame_at = Instant::now(); + this.send_host_frame(&terminal_view, cx); + } + + // Selection updates can be frequent; send smaller messages with a separate + // cap. + if selection_dirty.load(Ordering::Relaxed) + && last_selection_at.elapsed() >= Duration::from_millis(33) + { + selection_dirty.store(false, Ordering::Relaxed); + last_selection_at = Instant::now(); + this.send_host_selection_update(&terminal_view, cx); + } + true + }) + .unwrap_or(false); + + if !keep_running { + break; + } + } + }) + .detach(); + } + + fn subscribe_host_terminal_for_sharing_frames( + &mut self, + terminal: gpui::Entity, + dirty: Arc, + selection_dirty: Arc, + window: &mut Window, + cx: &mut Context, + ) { + let sub = cx.subscribe_in( + &terminal, + window, + move |_this, _terminal, event, _window, _cx| match event { + TerminalEvent::Wakeup => { + dirty.store(true, Ordering::Relaxed); + } + TerminalEvent::SelectionsChanged => { + selection_dirty.store(true, Ordering::Relaxed); + } + _ => {} + }, + ); + self._subscriptions.push(sub); + } + + fn handle_host_relay_message( + &mut self, + terminal_view_id: gpui::EntityId, + terminal_view: &gpui::Entity, + msg: RelayRelayToClient, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = cx + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + else { + return; + }; + match msg { + RelayRelayToClient::CtrlRequest { + room_id: _, + viewer_id, + viewer_label, + } => { + if host.controller_id.is_some() || host.pending_request { + host.conn.send(RelayClientToRelay::Denied { + room_id: host.room_id.clone(), + viewer_id, + reason: "busy".to_string(), + }); + return; + } + host.pending_request = true; + self.open_control_confirm_dialog( + terminal_view_id, + viewer_id, + viewer_label, + window, + cx, + ); + } + RelayRelayToClient::CtrlRelease { + room_id: _, + viewer_id, + } => { + if host.controller_id.as_deref() == Some(&viewer_id) { + host.conn.send(RelayClientToRelay::Released { + room_id: host.room_id.clone(), + viewer_id, + }); + host.controller_id = None; + } + } + RelayRelayToClient::CtrlReleased { + room_id: _, + viewer_id, + } => { + // The relay may clear control immediately on viewer release to avoid "busy" races. + // Treat CtrlReleased as authoritative and idempotent. + if host.controller_id.as_deref() == Some(&viewer_id) { + host.controller_id = None; + } + host.pending_request = false; + } + RelayRelayToClient::InputEvent { + room_id: _, + viewer_id, + payload, + } => { + let is_controller = host.controller_id.as_deref() == Some(&viewer_id); + let dirty = host.dirty.clone(); + let selection_dirty = host.selection_dirty.clone(); + if !is_controller { + return; + } + let Ok(ev) = serde_json::from_value::(payload) else { + return; + }; + let is_selection = matches!(ev, RemoteInputEvent::SetSelectionRange { .. }); + self.apply_remote_input_to_host_terminal(terminal_view_id, terminal_view, ev, cx); + if is_selection { + selection_dirty.store(true, Ordering::Relaxed); + } else { + dirty.store(true, Ordering::Relaxed); + } + } + RelayRelayToClient::Error { code: _, message } => { + notification::notify_deferred( + notification::MessageKind::Error, + message, + window, + cx, + ); + } + RelayRelayToClient::Ok + | RelayRelayToClient::Pong + | RelayRelayToClient::Joined { .. } + | RelayRelayToClient::Snapshot { .. } + | RelayRelayToClient::Frame { .. } + | RelayRelayToClient::Selection { .. } + | RelayRelayToClient::CtrlDenied { .. } + | RelayRelayToClient::CtrlGranted { .. } + | RelayRelayToClient::CtrlRevoked { .. } => {} + } + } + + fn handle_viewer_relay_message( + &mut self, + terminal_view_id: gpui::EntityId, + terminal: &gpui::Entity, + viewer_id: &Arc>>, + controlled: &Arc, + msg: RelayRelayToClient, + window: &mut Window, + cx: &mut Context, + ) { + match msg { + RelayRelayToClient::Joined { + room_id: _, + viewer_id: id, + } => { + if let Ok(mut guard) = viewer_id.lock() { + *guard = Some(id); + } + notification::notify_deferred( + notification::MessageKind::Info, + "Joined sharing.", + window, + cx, + ); + } + RelayRelayToClient::Snapshot { + room_id: _, + seq, + payload, + } => { + let Ok(content) = serde_json::from_value::(payload) else { + return; + }; + crate::sharing::apply_remote_snapshot( + terminal, + RemoteSnapshot { seq, content }, + cx, + ); + } + RelayRelayToClient::Frame { + room_id: _, + seq, + payload, + } => { + let Ok(content) = serde_json::from_value::(payload) else { + return; + }; + crate::sharing::apply_remote_frame(terminal, RemoteFrame { seq, content }, cx); + } + RelayRelayToClient::Selection { + room_id: _, + seq: _, + payload, + } => { + let Ok(update) = + serde_json::from_value::(payload) + else { + return; + }; + crate::sharing::apply_remote_selection_update(terminal, update, cx); + } + RelayRelayToClient::CtrlGranted { + room_id: _, + viewer_id: granted, + } => { + let mine = viewer_id.lock().ok().and_then(|v| v.clone()); + if mine.as_deref() != Some(&granted) { + return; + } + controlled.store(true, Ordering::Relaxed); + terminal.update(cx, |term, cx| { + term.dispatch_backend_event( + Box::new(RemoteBackendEvent::SetControlled(true)), + cx, + ); + }); + notification::notify_deferred( + notification::MessageKind::Info, + "Control granted.", + window, + cx, + ); + } + RelayRelayToClient::CtrlDenied { room_id: _, reason } => { + controlled.store(false, Ordering::Relaxed); + terminal.update(cx, |term, cx| { + term.dispatch_backend_event( + Box::new(RemoteBackendEvent::SetControlled(false)), + cx, + ); + }); + notification::notify_deferred( + notification::MessageKind::Warning, + format!("Control denied: {reason}"), + window, + cx, + ); + } + RelayRelayToClient::CtrlReleased { + room_id: _, + viewer_id: released, + } => { + let mine = viewer_id.lock().ok().and_then(|v| v.clone()); + if mine.as_deref() != Some(&released) { + return; + } + controlled.store(false, Ordering::Relaxed); + terminal.update(cx, |term, cx| { + term.dispatch_backend_event( + Box::new(RemoteBackendEvent::SetControlled(false)), + cx, + ); + }); + notification::notify_deferred( + notification::MessageKind::Info, + "Control released.", + window, + cx, + ); + } + RelayRelayToClient::CtrlRevoked { room_id: _ } => { + controlled.store(false, Ordering::Relaxed); + terminal.update(cx, |term, cx| { + term.dispatch_backend_event( + Box::new(RemoteBackendEvent::SetControlled(false)), + cx, + ); + }); + notification::notify_deferred( + notification::MessageKind::Warning, + "Control revoked.", + window, + cx, + ); + } + RelayRelayToClient::Error { code: _, message } => { + cx.global_mut::() + .viewers + .remove(&terminal_view_id); + controlled.store(false, Ordering::Relaxed); + terminal.update(cx, |term, cx| { + term.dispatch_backend_event( + Box::new(RemoteBackendEvent::SetControlled(false)), + cx, + ); + }); + notification::notify_deferred( + notification::MessageKind::Error, + message, + window, + cx, + ); + } + RelayRelayToClient::Ok + | RelayRelayToClient::Pong + | RelayRelayToClient::CtrlRequest { .. } + | RelayRelayToClient::CtrlRelease { .. } + | RelayRelayToClient::InputEvent { .. } => {} + } + } + + fn apply_remote_input_to_host_terminal( + &mut self, + terminal_view_id: gpui::EntityId, + terminal_view: &gpui::Entity, + ev: gpui_term::remote::RemoteInputEvent, + cx: &mut Context, + ) { + if terminal_view.entity_id() != terminal_view_id { + return; + } + + let terminal = terminal_view.read(cx).terminal.clone(); + match ev { + gpui_term::remote::RemoteInputEvent::Keystroke { keystroke } => { + if let Ok(k) = gpui::Keystroke::parse(&keystroke) { + let alt_is_meta = TerminalSettings::global(cx).option_as_meta; + terminal.update(cx, |t, _cx| { + t.try_keystroke(&k, alt_is_meta); + }); + } + } + gpui_term::remote::RemoteInputEvent::Paste { text } => { + terminal.update(cx, |t, _| t.paste(&text)); + } + gpui_term::remote::RemoteInputEvent::Text { text } => { + terminal.update(cx, |t, _| t.input(text.into_bytes())); + } + gpui_term::remote::RemoteInputEvent::ScrollLines { delta } => { + terminal.update(cx, |t, _| { + if delta > 0 { + t.scroll_up_by(delta as usize); + } else if delta < 0 { + t.scroll_down_by((-delta) as usize); + } + }); + } + gpui_term::remote::RemoteInputEvent::ScrollToTop => { + terminal.update(cx, |t, _| t.scroll_to_top()); + } + gpui_term::remote::RemoteInputEvent::ScrollToBottom => { + terminal.update(cx, |t, _| t.scroll_to_bottom()); + } + gpui_term::remote::RemoteInputEvent::SetSelectionRange { range } => { + terminal.update(cx, |t, _| { + t.set_selection_range(range.map(gpui_term::SelectionRange::from)); + }); + } + } + } + + fn open_control_confirm_dialog( + &mut self, + terminal_view_id: gpui::EntityId, + viewer_id: String, + viewer_label: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(Some(root)) = window.root::() else { + return; + }; + + let label = viewer_label.unwrap_or_else(|| viewer_id.clone()); + root.update(cx, |root, cx| { + root.open_dialog( + move |dialog, _window, _app| { + let detail = format!("Viewer requested control:\n{label}"); + dialog + .title("Request Control".to_string()) + .child(gpui_component::text::TextView::markdown( + "termua-sharing-ctrl-request", + detail, + )) + .button_props( + gpui_component::dialog::DialogButtonProps::default() + .ok_text("Grant".to_string()) + .cancel_text("Deny".to_string()), + ) + .on_ok({ + let viewer_id = viewer_id.clone(); + move |_, _window, app| { + if let Some(host) = app + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + { + host.pending_request = false; + host.controller_id = Some(viewer_id.clone()); + host.conn.send(RelayClientToRelay::Granted { + room_id: host.room_id.clone(), + viewer_id: viewer_id.clone(), + }); + } + true + } + }) + .on_cancel({ + let viewer_id = viewer_id.clone(); + move |_, _window, app| { + if let Some(host) = app + .global_mut::() + .hosts + .get_mut(&terminal_view_id) + { + host.pending_request = false; + host.conn.send(RelayClientToRelay::Denied { + room_id: host.room_id.clone(), + viewer_id: viewer_id.clone(), + reason: "denied".to_string(), + }); + } + true + } + }) + .confirm() + }, + window, + cx, + ); + }); + } + + pub(super) fn open_join_sharing_dialog(&mut self, window: &mut Window, cx: &mut Context) { + use gpui_component::input::{Input, InputState}; + + let Some(Some(root)) = window.root::() else { + return; + }; + + if !crate::sharing::sharing_feature_enabled(cx) { + notification::notify_deferred( + notification::MessageKind::Warning, + "Sharing is disabled in Settings.", + window, + cx, + ); + return; + } + + // Important: keep input state stable across renders. Creating `InputState` inside the + // dialog builder closure can cause it to be re-created on each re-render, making typing + // appear to "do nothing". + let relay_input = cx.new(|cx| InputState::new(window, cx)); + let share_key_input = + cx.new(|cx| InputState::new(window, cx).placeholder("AbC123xYz-k3Y9a1")); + let relay_url = crate::sharing::effective_relay_url(cx); + relay_input.update(cx, |state, cx| state.set_value(&relay_url, window, cx)); + + root.update(cx, |root, cx| { + let relay_input = relay_input.clone(); + let share_key_input = share_key_input.clone(); + + root.open_dialog( + move |dialog, _window, _app| { + dialog + .title("Join Sharing".to_string()) + .w(px(540.0)) + .child( + v_flex() + .gap_2() + .child("Relay URL".to_string()) + .child(Input::new(&relay_input)) + .child("Share Key".to_string()) + .child(Input::new(&share_key_input)), + ) + .button_props( + gpui_component::dialog::DialogButtonProps::default() + .ok_text("Join".to_string()) + .cancel_text("Cancel".to_string()), + ) + .on_ok({ + let relay_input = relay_input.clone(); + let share_key_input = share_key_input.clone(); + + move |_, window, app| { + let relay_url = relay_input.read(app).value().trim().to_string(); + let share_key = + share_key_input.read(app).value().trim().to_string(); + let command = match build_join_sharing_pending_command( + &relay_url, &share_key, + ) { + Ok(command) => command, + Err(err) => { + notification::notify_app( + notification::MessageKind::Warning, + err.to_string(), + window, + app, + ); + return false; + } + }; + app.global_mut::().pending_command(command); + app.refresh_windows(); + let _ = window; + true + } + }) + .confirm() + }, + window, + cx, + ); + }); + + let focus = share_key_input.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } +} + +#[cfg(test)] +mod tests { + use super::{JoinSharingInputError, build_join_sharing_pending_command}; + use crate::PendingCommand; + + #[test] + fn build_join_sharing_pending_command_accepts_valid_share_key() { + let command = + build_join_sharing_pending_command(" wss://relay.example/ws ", " AbC234xYz-k3Y9a2 ") + .expect("valid join sharing input"); + + match command { + PendingCommand::JoinRelaySharing { + relay_url, + room_id, + join_key, + } => { + assert_eq!(relay_url, "wss://relay.example/ws"); + assert_eq!(room_id, "AbC234xYz"); + assert_eq!(join_key, "k3Y9a2"); + } + other => panic!("expected JoinRelaySharing, got {other:?}"), + } + } + + #[test] + fn build_join_sharing_pending_command_rejects_empty_fields() { + assert_eq!( + build_join_sharing_pending_command("", "AbC234xYz-k3Y9a2") + .expect_err("empty relay URL"), + JoinSharingInputError::EmptyFields + ); + assert_eq!( + build_join_sharing_pending_command("ws://relay.example/ws", " ") + .expect_err("empty share key"), + JoinSharingInputError::EmptyFields + ); + } + + #[test] + fn build_join_sharing_pending_command_rejects_non_websocket_relay_url() { + assert_eq!( + build_join_sharing_pending_command("https://relay.example/ws", "AbC234xYz-k3Y9a2") + .expect_err("non-websocket relay URL"), + JoinSharingInputError::InvalidRelayUrl + ); + } + + #[test] + fn build_join_sharing_pending_command_rejects_invalid_share_key() { + let err = build_join_sharing_pending_command("ws://relay.example/ws", "bad-key") + .expect_err("invalid share key"); + + assert!(matches!(err, JoinSharingInputError::InvalidShareKey(_))); + assert!(err.to_string().starts_with("Invalid Share Key: ")); + } +} diff --git a/termua/src/window/main_window/actions/ssh.rs b/termua/src/window/main_window/actions/ssh.rs new file mode 100644 index 0000000..057c40a --- /dev/null +++ b/termua/src/window/main_window/actions/ssh.rs @@ -0,0 +1,651 @@ +use std::sync::Arc; + +use gpui::{ + App, AppContext, Context, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, + px, +}; +use gpui_common::TermuaIcon; +use gpui_component::{ + Icon, + button::{Button, ButtonVariants}, + h_flex, v_flex, +}; +use gpui_dock::{DockPlacement, PanelView}; +use gpui_term::{Authentication, SshOptions, TerminalBuilder, TerminalType}; +use rust_i18n::t; + +use super::TermuaWindow; +use crate::{ + PendingCommand, SshParams, TermuaAppState, + env::build_terminal_env, + notification, + panel::SshErrorPanel, + ssh::{ + SshHostKeyMismatchDetails, dedupe_tab_label, default_known_hosts_path, + parse_ssh_host_key_mismatch, remove_known_host_entry, ssh_connect_failure_message, + ssh_proxy_from_session, ssh_tab_tooltip, ssh_target_label, + }, +}; + +impl TermuaWindow { + fn open_ssh_host_verification_dialog( + &mut self, + opts: SshOptions, + message: String, + decision_tx: smol::channel::Sender, + window: &mut Window, + cx: &mut Context, + ) { + let Some(Some(root)) = window.root::() else { + log::warn!("termua: dialog requested but window root is not gpui_component::Root"); + let _ = decision_tx.try_send(false); + return; + }; + + let target = ssh_target_label(&opts); + + root.update(cx, |root, cx| { + root.open_dialog( + move |dialog, _window, app| { + let decision_tx_ok = decision_tx.clone(); + let decision_tx_cancel = decision_tx.clone(); + + dialog + .title(Self::ssh_host_verification_dialog_title(app)) + .w(px(720.)) + .child(Self::ssh_host_verification_dialog_body(&target, &message)) + .button_props( + gpui_component::dialog::DialogButtonProps::default() + .ok_text(t!("SshHostVerify.Button.TrustContinue").to_string()) + .cancel_text(t!("SshHostVerify.Button.Reject").to_string()), + ) + .on_ok(move |_, _window, _app| { + let _ = decision_tx_ok.try_send(true); + true + }) + .on_cancel(move |_, _window, _app| { + let _ = decision_tx_cancel.try_send(false); + true + }) + .confirm() + }, + window, + cx, + ); + }); + } + + fn ssh_host_verification_dialog_title(app: &App) -> gpui::AnyElement { + use gpui_component::ActiveTheme as _; + + h_flex() + .gap_2() + .items_center() + .child( + Icon::default() + .path(TermuaIcon::AlertCircle) + .text_color(app.theme().warning), + ) + .child(t!("SshHostVerify.Title").to_string()) + .into_any_element() + } + + fn ssh_host_verification_dialog_body(target: &str, message: &str) -> gpui::AnyElement { + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_start() + .child(div().child(t!("SshHostVerify.Label.Target").to_string())) + .child( + div().min_w_0().child( + gpui_component::text::TextView::markdown( + "termua-ssh-host-verify-text-target", + target.to_string(), + ) + .selectable(true), + ), + ), + ) + .child( + h_flex() + .gap_2() + .items_start() + .child(div().child(t!("SshHostVerify.Label.Message").to_string())) + .child( + div().min_w_0().child( + gpui_component::text::TextView::markdown( + "termua-ssh-host-verify-text-message", + message.to_string(), + ) + .selectable(true), + ), + ), + ) + .child( + h_flex() + .gap_2() + .items_start() + .child(div().child(t!("SshHostVerify.Label.Note").to_string())) + .child( + div().min_w_0().child( + gpui_component::text::TextView::markdown( + "termua-ssh-host-verify-text-note", + t!("SshHostVerify.NoteText").to_string(), + ) + .selectable(true), + ), + ), + ) + .into_any_element() + } + + fn ssh_host_key_mismatch_dialog_title(app: &App) -> gpui::AnyElement { + use gpui_component::ActiveTheme as _; + + h_flex() + .gap_2() + .items_center() + .child( + Icon::default() + .path(TermuaIcon::AlertCircle) + .text_color(app.theme().danger), + ) + .child(t!("SshHostKeyMismatch.Title").to_string()) + .into_any_element() + } + + fn ssh_host_key_mismatch_markdown_row( + label_selector: &'static str, + label: String, + value_selector: &'static str, + markdown_id: &'static str, + markdown: String, + ) -> gpui::AnyElement { + h_flex() + .gap_2() + .items_start() + .child( + div() + .debug_selector(|| label_selector.to_string()) + .child(label), + ) + .child( + div() + .min_w_0() + .debug_selector(|| value_selector.to_string()) + .child( + gpui_component::text::TextView::markdown(markdown_id, markdown) + .selectable(true), + ), + ) + .into_any_element() + } + + fn ssh_host_key_mismatch_dialog_body( + target: String, + host: String, + port: u16, + reason: String, + got_fingerprint: Option, + known_hosts_label: String, + fix_cmd: Option, + ) -> gpui::AnyElement { + let mut column = v_flex() + .gap_2() + .child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-target", + t!("SshHostKeyMismatch.Label.Target").to_string(), + "termua-ssh-hostkey-mismatch-value-target", + "termua-ssh-hostkey-mismatch-text-target", + target, + )) + .child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-server", + t!("SshHostKeyMismatch.Label.Server").to_string(), + "termua-ssh-hostkey-mismatch-value-server", + "termua-ssh-hostkey-mismatch-text-server", + format!("{host}:{port}"), + )) + .child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-reason", + t!("SshHostKeyMismatch.Label.Reason").to_string(), + "termua-ssh-hostkey-mismatch-value-reason", + "termua-ssh-hostkey-mismatch-text-reason", + reason, + )); + + if let Some(fp) = got_fingerprint { + column = column.child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-fingerprint", + t!("SshHostKeyMismatch.Label.GotFingerprint").to_string(), + "termua-ssh-hostkey-mismatch-value-fingerprint", + "termua-ssh-hostkey-mismatch-text-fingerprint", + fp, + )); + } + + column = column.child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-known-hosts", + t!("SshHostKeyMismatch.Label.KnownHosts").to_string(), + "termua-ssh-hostkey-mismatch-value-known-hosts", + "termua-ssh-hostkey-mismatch-text-known-hosts", + known_hosts_label, + )); + + if let Some(cmd) = fix_cmd { + column = column.child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-manual-fix", + t!("SshHostKeyMismatch.Label.ManualFix").to_string(), + "termua-ssh-hostkey-mismatch-value-manual-fix", + "termua-ssh-hostkey-mismatch-text-manual-fix", + cmd, + )); + } + + column + .child(Self::ssh_host_key_mismatch_markdown_row( + "termua-ssh-hostkey-mismatch-label-note", + t!("SshHostKeyMismatch.Label.Note").to_string(), + "termua-ssh-hostkey-mismatch-value-note", + "termua-ssh-hostkey-mismatch-text-note", + t!("SshHostKeyMismatch.NoteText").to_string(), + )) + .into_any_element() + } + + fn close_dialog(window: &mut Window, app: &mut App) { + gpui_component::Root::update(window, app, |root, window, cx| { + root.close_dialog(window, cx); + }); + } + + fn queue_open_ssh_terminal(backend_type: TerminalType, params: SshParams, app: &mut App) { + app.global_mut::() + .pending_commands + .push(PendingCommand::OpenSshTerminal { + backend_type, + params, + }); + app.refresh_windows(); + } + + fn ssh_host_key_mismatch_dialog_footer_elements( + backend_type: TerminalType, + params: SshParams, + known_hosts_path: Option, + host: String, + port: u16, + cancel: C, + window: &mut Window, + app: &mut App, + ) -> Vec + where + C: FnOnce(&mut Window, &mut App) -> gpui::AnyElement, + { + let retry_params = params.clone(); + let retry_button = Button::new("termua-ssh-hostkey-mismatch-retry") + .label(t!("SshHostKeyMismatch.Button.Retry").to_string()) + .on_click(move |_, window, app| { + Self::close_dialog(window, app); + Self::queue_open_ssh_terminal(backend_type, retry_params.clone(), app); + }); + + let remove_params = params; + let remove_and_retry_button = Button::new("termua-ssh-hostkey-mismatch-remove-retry") + .label(t!("SshHostKeyMismatch.Button.RemoveRetry").to_string()) + .primary() + .on_click(move |_, window, app| { + let Some(path) = known_hosts_path.as_ref() else { + notification::notify_app( + notification::MessageKind::Error, + t!("SshHostKeyMismatch.Error.MissingKnownHostsPath").to_string(), + window, + app, + ); + return; + }; + + let summary = match remove_known_host_entry(path, &host, port) { + Ok(summary) => summary, + Err(err) => { + notification::notify_app( + notification::MessageKind::Error, + t!( + "SshHostKeyMismatch.Error.FailedUpdateKnownHosts", + err = format!("{err:#}") + ) + .to_string(), + window, + app, + ); + return; + } + }; + + Self::close_dialog(window, app); + + notification::notify_app( + notification::MessageKind::Info, + if summary.is_empty() { + format!("Updated {}", path.display()) + } else { + summary + }, + window, + app, + ); + + Self::queue_open_ssh_terminal(backend_type, remove_params.clone(), app); + }); + + vec![ + cancel(window, app), + retry_button.into_any_element(), + remove_and_retry_button.into_any_element(), + ] + } + + pub(crate) fn open_ssh_host_key_mismatch_dialog( + &mut self, + backend_type: TerminalType, + params: SshParams, + reason: String, + details: SshHostKeyMismatchDetails, + window: &mut Window, + cx: &mut Context, + ) { + let Some(Some(root)) = window.root::() else { + log::warn!("termua: dialog requested but window root is not gpui_component::Root"); + return; + }; + + let target = ssh_target_label(¶ms.opts); + let default_host = params.opts.host.trim().to_string(); + let default_port = params.opts.port.unwrap_or(22); + let host = details + .server_host + .clone() + .unwrap_or_else(|| default_host.clone()); + let port = details.server_port.unwrap_or(default_port); + + let known_hosts_path = details + .known_hosts_path + .clone() + .or_else(default_known_hosts_path); + + let known_hosts_label = known_hosts_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.ssh/known_hosts".to_string()); + + let fix_cmd = known_hosts_path.as_ref().map(|p| { + if port == 22 { + format!("ssh-keygen -R \"{host}\" -f \"{}\"", p.display()) + } else { + format!("ssh-keygen -R \"[{host}]:{port}\" -f \"{}\"", p.display()) + } + }); + + root.update(cx, |root, cx| { + root.open_dialog( + move |dialog, _window, app| { + let known_hosts_path_for_footer = known_hosts_path.clone(); + let host_for_footer = host.clone(); + let port_for_footer = port; + let params_for_footer = params.clone(); + + dialog + .title(Self::ssh_host_key_mismatch_dialog_title(app)) + .w(px(720.)) + .child(Self::ssh_host_key_mismatch_dialog_body( + target.clone(), + host.clone(), + port, + reason.clone(), + details.got_fingerprint.clone(), + known_hosts_label.clone(), + fix_cmd.clone(), + )) + .footer(move |_ok, cancel, window, app| { + Self::ssh_host_key_mismatch_dialog_footer_elements( + backend_type, + params_for_footer.clone(), + known_hosts_path_for_footer.clone(), + host_for_footer.clone(), + port_for_footer, + cancel, + window, + app, + ) + }) + }, + window, + cx, + ); + }); + } + + pub(crate) fn add_ssh_terminal_with_params( + &mut self, + backend_type: TerminalType, + params: SshParams, + session_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + // Building the SSH PTY involves a blocking login handshake. Run that work in a background + // thread and only attach the terminal panel on success. + let builder_fn = self.ssh_terminal_builder.clone(); + let env_for_thread = params.env.clone(); + let opts_for_thread = params.opts.clone(); + let params_for_finish = params.clone(); + let opts_for_prompt = params.opts; + let background = cx.background_executor().clone(); + + let (verify_tx, verify_rx) = + smol::channel::unbounded::(); + // Keep a sender alive on the UI thread so closing the prompt channel can't happen on the + // background thread. + let verify_tx_keepalive = verify_tx.clone(); + + // Handle host verification prompts while the background handshake is running. + cx.spawn_in(window, async move |view, window| { + while let Ok(req) = verify_rx.recv().await { + let (decision_tx, decision_rx) = smol::channel::bounded::(1); + let message = req.message; + let _ = view.update_in(window, |this, window, cx| { + this.open_ssh_host_verification_dialog( + opts_for_prompt.clone(), + message, + decision_tx.clone(), + window, + cx, + ); + }); + + let decision = decision_rx.recv().await.unwrap_or(false); + let _ = req.reply.send(decision).await; + } + }) + .detach(); + + cx.spawn_in(window, async move |view, window| { + let session_id = session_id; + let verify_tx_for_task = verify_tx.clone(); + let task = background.spawn(async move { + // Route SSH host verification prompts (unknown host keys) back to the UI thread. + // If no UI consumes them, gpui_term will time out and treat the host as untrusted. + let _guard = gpui_term::set_thread_ssh_host_verification_prompt_sender(Some( + verify_tx_for_task, + )); + (builder_fn)(backend_type, env_for_thread, opts_for_thread) + }); + + let result = task.await; + + let _ = view.update_in(window, |this, window, cx| { + this.finish_add_ssh_terminal_task( + result, + backend_type, + params_for_finish, + session_id, + window, + cx, + ); + }); + + // Keep the sender alive on the UI thread until the handshake completes. + drop(verify_tx_keepalive); + }) + .detach(); + } + + fn finish_add_ssh_terminal_task( + &mut self, + result: anyhow::Result, + backend_type: TerminalType, + params: SshParams, + session_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + match result { + Ok(builder) => { + let panel = self.build_ssh_panel_from_builder( + builder, + params.name, + params.opts, + window, + cx, + ); + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + self.clear_connecting_session(session_id, cx); + cx.notify(); + } + Err(err) => { + let root_reason = err.root_cause().to_string(); + if let Some(details) = parse_ssh_host_key_mismatch(&root_reason) { + self.open_ssh_host_key_mismatch_dialog( + backend_type, + params, + root_reason, + details, + window, + cx, + ); + self.clear_connecting_session(session_id, cx); + return; + } + let id = self.next_terminal_id; + self.next_terminal_id += 1; + + let tab_label = + dedupe_tab_label(&mut self.ssh_tab_label_counts, params.name.as_str()); + let tab_tooltip = ssh_tab_tooltip(¶ms.opts); + let message = ssh_connect_failure_message(¶ms.opts, &err); + + let panel = cx.new(|cx| { + SshErrorPanel::new(id, tab_label, Some(tab_tooltip), message.into(), cx) + }); + + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + self.clear_connecting_session(session_id, cx); + cx.notify(); + } + } + } + + fn clear_connecting_session(&mut self, session_id: Option, cx: &mut Context) { + let Some(session_id) = session_id else { + return; + }; + self.sessions_sidebar.update(cx, |sidebar, cx| { + sidebar.set_connecting(session_id, false, cx); + }); + } + + pub(super) fn open_saved_ssh_session( + &mut self, + backend_type: TerminalType, + session: crate::store::Session, + session_id: i64, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = session.ssh_host.as_deref() else { + return; + }; + let port = session.ssh_port.unwrap_or(22); + + let session_env = session.env.clone().unwrap_or_default(); + let env = build_terminal_env( + "", + session.term(), + session.colorterm(), + session.charset(), + &session_env, + ); + let proxy = ssh_proxy_from_session(&session); + let name = session.label; + + let auth = match session.ssh_auth_type { + Some(crate::store::SshAuthType::Config) => Authentication::Config, + Some(crate::store::SshAuthType::Password) => { + let user = session.ssh_user.unwrap_or_else(|| "root".to_string()); + let password = session.ssh_password.unwrap_or_default(); + if password.trim().is_empty() { + notification::notify_deferred( + notification::MessageKind::Error, + "Missing saved SSH password for this session.", + window, + cx, + ); + return; + } + Authentication::Password(user, password) + } + None => { + // Back-compat: default to config auth if not recorded. + Authentication::Config + } + }; + + let opts = SshOptions { + host: host.to_string(), + port: Some(port), + auth, + proxy, + backend: cx + .try_global::() + .map(|pref| pref.backend) + .unwrap_or_default(), + tcp_nodelay: session.ssh_tcp_nodelay, + tcp_keepalive: session.ssh_tcp_keepalive, + }; + self.add_ssh_terminal_with_params( + backend_type, + SshParams { env, name, opts }, + Some(session_id), + window, + cx, + ); + } +} diff --git a/termua/src/window/main_window/actions/terminal.rs b/termua/src/window/main_window/actions/terminal.rs new file mode 100644 index 0000000..6df42c7 --- /dev/null +++ b/termua/src/window/main_window/actions/terminal.rs @@ -0,0 +1,768 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex, atomic::AtomicBool}, +}; + +use gpui::{AppContext, Context, FocusHandle, ReadGlobal, SharedString, Window}; +use gpui_dock::{DockPlacement, PanelView}; +use gpui_term::{ + CursorShape, Event as TerminalEvent, PtySource, SerialOptions, SshOptions, TerminalBuilder, + TerminalSettings, TerminalType, TerminalView, UserInput as TerminalUserInput, + remote::RemoteInputEvent, +}; + +use super::TermuaWindow; +use crate::{ + SerialParams, TermuaAppState, + env::{build_terminal_env, cast_player_child_env}, + lock_screen, notification, + panel::{PanelKind, TerminalPanel, terminal_panel_tab_name}, + sharing::{ + ClientToRelay as RelayClientToRelay, RelaySharingState, ViewerShare, compose_share_key, + connect_relay, + }, + ssh::{dedupe_tab_label, ssh_tab_tooltip}, +}; + +impl TermuaWindow { + pub(super) fn add_local_terminal(&mut self, window: &mut Window, cx: &mut Context) { + self.add_local_terminal_with_params(TerminalType::WezTerm, HashMap::new(), window, cx); + } + + pub(super) fn open_cast_player_picker(&mut self, window: &mut Window, cx: &mut Context) { + use gpui::PathPromptOptions; + + let picker = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select cast file to play".into()), + }); + + cx.spawn_in(window, async move |view, window| { + let Ok(Ok(Some(mut paths))) = picker.await else { + return; + }; + let Some(path) = paths.pop() else { + return; + }; + + let _ = view.update_in(window, |this, window, cx| { + this.open_cast_player_tab(path, window, cx); + }); + }) + .detach(); + } + + fn open_cast_player_tab( + &mut self, + cast_path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let playback_speed = cx + .try_global::() + .map(crate::settings::RecordingSettings::playback_speed_or_default) + .unwrap_or(1.0); + let env = cast_player_child_env(&cast_path, playback_speed); + + let panel = + self.build_terminal_panel(PanelKind::Recorder, TerminalType::WezTerm, env, window, cx); + + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel.clone()) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + + pub(super) fn add_local_terminal_with_params( + &mut self, + backend_type: TerminalType, + env: HashMap, + window: &mut Window, + cx: &mut Context, + ) { + let panel = self.build_terminal_panel(PanelKind::Local, backend_type, env, window, cx); + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + // `DockArea` will re-render itself, but we also mutate our own state (`next_terminal_id`), + // so we must notify. + cx.notify(); + } + + pub(super) fn add_relay_viewer_terminal( + &mut self, + relay_url: String, + room_id: String, + join_key: String, + window: &mut Window, + cx: &mut Context, + ) { + cx.spawn_in(window, async move |this, window| { + let conn = connect_relay( + &relay_url, + RelayClientToRelay::Join { + room_id: room_id.clone(), + join_key: join_key.clone(), + }, + ) + .await; + + let _ = this.update_in(window, move |this, window, cx| match conn { + Ok(conn) => { + let id = this.next_terminal_id; + this.next_terminal_id += 1; + + let tab_label: SharedString = format!("share {id}").into(); + let tab_tooltip: SharedString = + format!("Share Key {}", compose_share_key(&room_id, &join_key)).into(); + + let viewer_id = Arc::new(Mutex::new(None::)); + let controlled = Arc::new(AtomicBool::new(false)); + + let room_id_for_input = room_id.clone(); + let conn_for_input = conn.clone(); + let viewer_id_for_input = Arc::clone(&viewer_id); + let send_input: Arc = + Arc::new(move |ev| { + let Some(viewer_id) = + viewer_id_for_input.lock().ok().and_then(|v| v.clone()) + else { + return; + }; + let Ok(payload) = serde_json::to_value(ev) else { + return; + }; + conn_for_input.send(RelayClientToRelay::InputEvent { + room_id: room_id_for_input.clone(), + viewer_id, + payload, + }); + }); + + let terminal = crate::sharing::make_remote_terminal( + send_input, + Arc::clone(&controlled), + cx, + ); + let panel = this.build_wired_terminal_panel( + id, + PanelKind::Local, + tab_label, + Some(tab_tooltip), + terminal, + window, + cx, + ); + let terminal_view = panel.read(cx).terminal_view(); + + let terminal_view_id = terminal_view.entity_id(); + cx.global_mut::().viewers.insert( + terminal_view_id, + ViewerShare { + room_id, + viewer_id: Arc::clone(&viewer_id), + controlled: Arc::clone(&controlled), + conn, + }, + ); + + let relay_terminal = terminal_view.read(cx).terminal.clone(); + this.spawn_relay_pump_for_viewer(terminal_view_id, relay_terminal, window, cx); + this.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + cx.notify(); + } + Err(err) => { + notification::notify_deferred( + notification::MessageKind::Error, + format!("Join sharing failed: {err:#}"), + window, + cx, + ); + } + }); + }) + .detach(); + } + + pub(super) fn add_serial_terminal_with_params( + &mut self, + backend_type: TerminalType, + params: SerialParams, + session_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + let opts = params.to_options(); + + log::debug!( + "termua: opening serial session (backend={backend_type:?}) port={} baud={}", + opts.port, + opts.baud + ); + + let builder = TerminalBuilder::new_with_pty( + backend_type, + PtySource::Serial { opts: opts.clone() }, + CursorShape::default(), + None, + ); + + let builder = match builder { + Ok(builder) => builder, + Err(err) => { + if let Some(_session_id) = session_id { + let reason = err.root_cause().to_string(); + let hint = crate::serial::open_failure_hint(¶ms.port, &err); + let message: SharedString = match hint { + Some(hint) => format!( + "Failed to open serial port `{}`.\n\nError:\n{reason}\n\n{hint}", + params.port + ) + .into(), + None => format!( + "Failed to open serial port `{}`.\n\nError:\n{reason}", + params.port + ) + .into(), + }; + + // Clicking a saved Serial session should only show a toast; editing the + // session (e.g. changing port) is done via right-click → Edit. + let message: SharedString = format!( + "{message}\n\nTip: Right-click the session and choose Edit to change the \ + port." + ) + .into(); + window.defer(cx, move |window, app| { + crate::notification::notify_app( + crate::notification::MessageKind::Error, + message, + window, + app, + ); + }); + return; + } + + let reason = err.root_cause().to_string(); + let hint = crate::serial::open_failure_hint(¶ms.port, &err); + let message = match hint { + Some(hint) => format!( + "Failed to open serial port `{}`.\n\nError:\n{reason}\n\n{hint}", + params.port + ), + None => format!( + "Failed to open serial port `{}`.\n\nError:\n{reason}", + params.port + ), + }; + + notification::notify_deferred( + notification::MessageKind::Error, + message, + window, + cx, + ); + return; + } + }; + + let panel = self.build_serial_panel_from_builder(builder, params.name, opts, window, cx); + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + cx.notify(); + } + + pub(in crate::window::main_window) fn open_session_by_id( + &mut self, + id: i64, + window: &mut Window, + cx: &mut Context, + ) { + let Ok(Some(session)) = crate::store::load_session(id) else { + return; + }; + + let backend_type = match session.backend { + crate::settings::TerminalBackend::Alacritty => TerminalType::Alacritty, + crate::settings::TerminalBackend::Wezterm => TerminalType::WezTerm, + }; + + let protocol = session.protocol.clone(); + match protocol { + crate::store::SessionType::Local => { + self.open_saved_local_session(backend_type, session, window, cx); + } + crate::store::SessionType::Ssh => { + self.open_saved_ssh_session(backend_type, session, id, window, cx); + } + crate::store::SessionType::Serial => { + self.open_saved_serial_session(backend_type, session, id, window, cx); + } + } + } + + fn open_saved_local_session( + &mut self, + backend_type: TerminalType, + session: crate::store::Session, + window: &mut Window, + cx: &mut Context, + ) { + let session_env = session.env.clone().unwrap_or_default(); + let env = build_terminal_env( + gpui_term::shell::default_shell_program(), + session.term(), + session.colorterm(), + session.charset(), + &session_env, + ); + self.add_local_terminal_with_params(backend_type, env, window, cx); + } + + fn open_saved_serial_session( + &mut self, + backend_type: TerminalType, + session: crate::store::Session, + session_id: i64, + window: &mut Window, + cx: &mut Context, + ) { + let Some(port) = session.serial_port.clone() else { + notification::notify_deferred( + notification::MessageKind::Error, + "Missing saved serial port for this session.", + window, + cx, + ); + return; + }; + + let baud = session.serial_baud.unwrap_or(9600); + let data_bits = session.serial_data_bits.unwrap_or(8); + let parity = session + .serial_parity + .unwrap_or(crate::store::SerialParity::None); + let stop_bits = session + .serial_stop_bits + .unwrap_or(crate::store::SerialStopBits::One); + let flow_control = session + .serial_flow_control + .unwrap_or(crate::store::SerialFlowControl::None); + + self.add_serial_terminal_with_params( + backend_type, + SerialParams { + name: session.label, + port, + baud, + data_bits, + parity, + stop_bits, + flow_control, + }, + Some(session_id), + window, + cx, + ); + } + + pub(super) fn reload_sessions_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + let sidebar = self.sessions_sidebar.clone(); + sidebar.update(cx, |sidebar, cx| { + sidebar.reload(window, cx); + }); + } + + fn register_terminal_target_and_focus( + &mut self, + id: usize, + tab_label: SharedString, + terminal_view: &gpui::Entity, + terminal_weak: gpui::WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + crate::assistant::register_terminal_target(cx, id, tab_label, terminal_weak.clone()); + + let focused_terminal_view = terminal_view.downgrade(); + let focused_terminal = terminal_weak; + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + let sub = cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { + this.focused_terminal_view = Some(focused_terminal_view.clone()); + crate::assistant::set_focused_terminal(cx, Some(id), Some(focused_terminal.clone())); + }); + self._subscriptions.push(sub); + } + + pub(crate) fn subscribe_terminal_view_events( + &mut self, + terminal_view: &gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + let source_terminal_view = terminal_view.clone(); + let source_terminal_view_for_cb = source_terminal_view.clone(); + let subscription = cx.subscribe_in( + &source_terminal_view, + window, + move |this, _, event, window, cx| match event { + TerminalEvent::UserInput(input) => { + if this.close_exited_terminal_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, + detail, + } => { + let kind = match level { + gpui::PromptLevel::Info => crate::notification::MessageKind::Info, + gpui::PromptLevel::Warning => crate::notification::MessageKind::Warning, + gpui::PromptLevel::Critical => crate::notification::MessageKind::Error, + }; + let message = match detail.as_deref() { + Some(detail) if !detail.trim().is_empty() => format!("{title}\n{detail}"), + _ => title.clone(), + }; + crate::notification::record(kind, message, cx); + } + _ => {} + }, + ); + self._subscriptions.push(subscription); + } + + fn create_terminal_view( + &self, + kind: PanelKind, + terminal: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + if kind == PanelKind::Recorder { + return cx.new(|cx| TerminalView::new_with_context_menu(terminal, window, cx, false)); + } + + let provider = self.terminal_context_menu_provider.clone(); + cx.new(|cx| { + TerminalView::new_with_context_menu_provider(terminal, window, cx, true, Some(provider)) + }) + } + + fn build_wired_terminal_panel( + &mut self, + id: usize, + kind: PanelKind, + tab_label: SharedString, + tab_tooltip: Option, + terminal: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + self.subscribe_terminal_events_for_messages( + terminal.clone(), + id, + tab_label.clone(), + window, + cx, + ); + + let terminal_weak = terminal.downgrade(); + let terminal_view = self.create_terminal_view(kind, terminal, window, cx); + + self.register_terminal_target_and_focus( + id, + tab_label.clone(), + &terminal_view, + terminal_weak, + window, + cx, + ); + self.subscribe_terminal_view_events(&terminal_view, window, cx); + + let focus: FocusHandle = terminal_view.read(cx).focus_handle.clone(); + window.focus(&focus, cx); + + cx.new(|_| TerminalPanel::new(id, kind, tab_label, tab_tooltip, terminal_view)) + } + + pub(super) fn build_ssh_panel_from_builder( + &mut self, + builder: TerminalBuilder, + name: String, + opts: SshOptions, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + let id = self.next_terminal_id; + self.next_terminal_id += 1; + + let tab_label = dedupe_tab_label(&mut self.ssh_tab_label_counts, name.as_str()); + let tab_tooltip = ssh_tab_tooltip(&opts); + + let terminal = cx.new(move |cx| builder.subscribe(cx)); + self.build_wired_terminal_panel( + id, + PanelKind::Ssh, + tab_label, + Some(tab_tooltip), + terminal, + window, + cx, + ) + } + + fn build_serial_panel_from_builder( + &mut self, + builder: TerminalBuilder, + name: String, + opts: SerialOptions, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + let id = self.next_terminal_id; + self.next_terminal_id += 1; + + let tab_label = terminal_panel_tab_name(PanelKind::Serial, id); + let tab_tooltip: SharedString = format!("{name}\n{} @ {}", opts.port, opts.baud).into(); + + let terminal = cx.new(move |cx| builder.subscribe(cx)); + self.build_wired_terminal_panel( + id, + PanelKind::Serial, + tab_label, + Some(tab_tooltip), + terminal, + window, + cx, + ) + } + + fn build_terminal_panel( + &mut self, + kind: PanelKind, + backend_type: TerminalType, + env: HashMap, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + let id = self.next_terminal_id; + self.next_terminal_id += 1; + + let env = match kind { + PanelKind::Local => crate::shell_integration::maybe_inject_local_shell_osc133(env, id), + PanelKind::Ssh | PanelKind::Serial | PanelKind::Recorder => env, + }; + + let tab_label = match kind { + PanelKind::Local => crate::panel::local_terminal_panel_tab_name( + &env, + id, + &mut self.local_tab_label_counts, + ), + PanelKind::Ssh | PanelKind::Serial | PanelKind::Recorder => { + terminal_panel_tab_name(kind, id) + } + }; + + let terminal = cx.new(|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) + } + + pub(super) fn open_sftp_for_terminal_view( + &mut self, + terminal_view: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + let mut tab_label: gpui::SharedString = "SFTP".into(); + let tab_panels = self.dock_area.read(cx).visible_tab_panels(cx); + for tab_panel in tab_panels { + let Some(active_panel) = tab_panel.read(cx).active_panel(cx) else { + continue; + }; + + let Ok(terminal_panel) = active_panel.view().downcast::() else { + continue; + }; + + let terminal_panel = terminal_panel.read(cx); + if terminal_panel.terminal_view().entity_id() == terminal_view.entity_id() { + tab_label = terminal_panel.tab_label(); + break; + } + } + + let panel = match crate::panel::sftp_panel::SftpDockPanel::open_for_terminal_view( + terminal_view, + tab_label, + window, + cx, + ) { + Ok(panel) => panel, + Err(err) => { + notification::notify_deferred( + notification::MessageKind::Error, + err.to_string(), + window, + cx, + ); + return; + } + }; + + self.dock_area.update(cx, |dock, cx| { + dock.add_panel(panel, DockPlacement::Bottom, None, window, cx); + if !dock.is_dock_open(DockPlacement::Bottom, cx) { + dock.toggle_dock(DockPlacement::Bottom, window, cx); + } + }); + cx.notify(); + } + + fn close_exited_terminal_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| { + matches!(terminal_panel.kind(), PanelKind::Ssh | PanelKind::Recorder) + && 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, + input: TerminalUserInput, + cx: &mut Context, + ) { + cx.global::().report_activity(); + + if cx.global::().locked() { + return; + } + + if !cx.global::().multi_exec_enabled { + return; + } + + // Only broadcast to panes that are currently visible: the active tab in each visible + // TabPanel (splits). This intentionally skips background tabs. + let tab_panels = self.dock_area.read(cx).visible_tab_panels(cx); + for tab_panel in tab_panels { + let Some(active_panel) = tab_panel.read(cx).active_panel(cx) else { + continue; + }; + + let Ok(terminal_panel) = active_panel.view().downcast::() else { + continue; + }; + + let target_terminal_view = terminal_panel.read(cx).terminal_view(); + if target_terminal_view.entity_id() == source.entity_id() { + continue; + } + + match &input { + TerminalUserInput::Keystroke(keystroke) => { + let keystroke = keystroke.clone(); + target_terminal_view.update(cx, |view, cx| { + view.terminal.update(cx, |term, cx| { + term.try_keystroke( + &keystroke, + TerminalSettings::global(cx).option_as_meta, + ); + }); + }); + } + TerminalUserInput::Text(text) => { + let bytes = text.clone().into_bytes(); + target_terminal_view.update(cx, |view, cx| { + view.terminal.update(cx, |term, _| { + term.input(bytes.clone()); + }); + }); + } + TerminalUserInput::Paste(text) => { + let text = text.clone(); + target_terminal_view.update(cx, |view, cx| { + view.terminal.update(cx, |term, _| { + term.paste(&text); + }); + }); + } + } + } + } +} diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 4ada4ec..0ad6701 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -494,12 +494,7 @@ fn ssh_connect_clears_sessions_sidebar_connecting_state(cx: &mut gpui::TestAppCo app.set_global(TermuaAppState::default()); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-ssh-sidebar-connecting-cleared-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("ssh-sidebar-connecting-cleared"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id = crate::store::save_ssh_session_config( @@ -1083,6 +1078,122 @@ fn close_terminal_event_keeps_recorder_tab_open(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +fn exited_recorder_tab_closes_on_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( + 79, + crate::panel::PanelKind::Recorder, + "recorder 79".into(), + None, + terminal_view.clone(), + ) + }); + + termua.update(app, |this, cx| { + this.subscribe_terminal_events_for_messages( + terminal, + 79, + "recorder 79".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| { + 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 recorder tab 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}; @@ -2068,12 +2179,7 @@ fn fullscreen_with_terminal_tab_does_not_block_sessions_tree_clicks(cx: &mut gpu app.set_global(TermuaAppState::default()); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-click-through-fullscreen-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("sessions-click-through-fullscreen"); let _guard = crate::store::tests::override_termua_db_path(db_path); let session_id_1 = crate::store::save_ssh_session_password( @@ -2431,12 +2537,7 @@ fn ssh_sessions_with_missing_password_show_a_notification(cx: &mut gpui::TestApp app.set_global(TermuaAppState::default()); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-sessions-test-missing-password-{}", - std::process::id() - )); - std::fs::create_dir_all(&tmp_dir).unwrap(); - let db_path = tmp_dir.join("termua").join("termua.db"); + let db_path = crate::store::tests::unique_test_db_path("missing-password"); let _guard = crate::store::tests::override_termua_db_path(db_path); let id = crate::store::save_ssh_session_password( diff --git a/termua/src/window/new_session/actions.rs b/termua/src/window/new_session/actions.rs index 4cd042b..c63b3ef 100644 --- a/termua/src/window/new_session/actions.rs +++ b/termua/src/window/new_session/actions.rs @@ -96,7 +96,6 @@ enum SessionStoreOp { group: String, label: String, backend: crate::settings::TerminalBackend, - shell_program: String, env: Vec, }, UpdateLocal { @@ -104,7 +103,6 @@ enum SessionStoreOp { group: String, label: String, backend: crate::settings::TerminalBackend, - shell_program: String, env: Vec, }, SaveSshPassword { @@ -207,7 +205,6 @@ impl SessionStoreOp { group, label, backend, - shell_program, env, } => { let (term, colorterm, charset) = session_store_terminal_fields_from_env(&env); @@ -215,7 +212,6 @@ impl SessionStoreOp { group.as_str(), label.as_str(), backend, - shell_program.as_str(), term.as_str(), colorterm.as_deref(), charset.as_str(), @@ -227,7 +223,6 @@ impl SessionStoreOp { group, label, backend, - shell_program, env, } => { let (term, colorterm, charset) = session_store_terminal_fields_from_env(&env); @@ -236,7 +231,6 @@ impl SessionStoreOp { group.as_str(), label.as_str(), backend, - shell_program.as_str(), term.as_str(), colorterm.as_deref(), charset.as_str(), @@ -644,7 +638,6 @@ impl NewSessionWindow { group, label, backend: backend_for_store, - shell_program: shell_program.to_string(), env: session_store_env_from_fields( term.as_ref(), Self::trimmed_non_empty_option(colorterm.as_str()), @@ -820,7 +813,6 @@ impl NewSessionWindow { group, label, backend: backend_for_store, - shell_program: shell_program.to_string(), env: session_store_env_from_fields( term.as_ref(), Self::trimmed_non_empty_option(colorterm.as_str()), @@ -1237,11 +1229,9 @@ impl NewSessionWindow { window: &mut Window, cx: &mut Context, ) { - let program = session - .shell_program - .as_deref() - .unwrap_or(gpui_term::shell::fallback_shell_program()); - self.shell.set_program(program, window, cx); + let _ = session; + self.shell + .set_program(gpui_term::shell::default_shell_program(), window, cx); // The shell program may auto-sync the label; restore the persisted label/group. set_input_value( @@ -1528,7 +1518,6 @@ impl NewSessionWindow { group.as_str(), label.as_str(), backend_for_store, - shell_program.as_ref(), term.as_str(), colorterm.as_deref(), charset.as_str(), diff --git a/termua/src/window/new_session/mod.rs b/termua/src/window/new_session/mod.rs index 267bd7a..48af47d 100644 --- a/termua/src/window/new_session/mod.rs +++ b/termua/src/window/new_session/mod.rs @@ -33,9 +33,9 @@ pub use state::Protocol; use state::{ BackendSelectItem, EnvRowState, ProxyEnvRowState, ProxyJumpRowState, SerialDataBitsSelectItem, SerialFlowControlSelectItem, SerialParitySelectItem, SerialSessionState, - SerialStopBitsSelectItem, SessionCommonState, SessionEditorMode, ShellProgramSelectItem, - ShellSessionState, SshAuthSelectItem, SshAuthType, SshProxySelectItem, SshSessionState, - TermBackend, + SerialStopBitsSelectItem, SessionCommonState, SessionEditorMode, ShellSessionState, + SshAuthSelectItem, SshAuthType, SshProxySelectItem, SshSessionState, TermBackend, + shell_program_title, }; pub struct NewSessionWindow { @@ -585,20 +585,6 @@ impl NewSessionWindow { } } })); - self._subscriptions - .push(cx.subscribe_in(&self.shell.program_select, window, { - move |this, - _select, - ev: &SelectEvent>, - window, - cx| { - if let SelectEvent::Confirm(Some(program)) = ev { - this.shell.set_program(program.value().as_ref(), window, cx); - cx.notify(); - window.refresh(); - } - } - })); self._subscriptions.push( cx.subscribe_in(&self.shell.common.colorterm_select, window, { move |this, _select, ev: &SelectEvent>, window, cx| { @@ -970,7 +956,7 @@ impl SessionCommonState { impl ShellSessionState { fn program_default_value() -> SharedString { - gpui_term::shell::fallback_shell_program().into() + gpui_term::shell::default_shell_program().into() } fn new( @@ -985,21 +971,10 @@ impl ShellSessionState { "termua-new-session-shell-type-icon", ); - let program_options = gpui_term::shell::shell_program_items() - .into_iter() - .map(|p| ShellProgramSelectItem::new(SharedString::from(p))) - .collect::>(); - let program = program_options - .first() - .map(|item| item.program.clone()) - .unwrap_or_else(Self::program_default_value); - - let program_select = new_select(window, cx, program_options.clone(), Some(0)); + let program = Self::program_default_value(); let this = Self { program, - program_options, - program_select, env_rows: Vec::new(), env_next_id: 1, common, @@ -1007,7 +982,7 @@ impl ShellSessionState { // Initialize label to match the selected program. let program = this.program.clone(); - let label = ShellProgramSelectItem::new(program).title(); + let label = shell_program_title(program.as_ref()); this.common.label_input.update(cx, move |input, cx| { input.set_value(label.clone(), window, cx); }); @@ -1023,28 +998,11 @@ impl ShellSessionState { ) { let old_program = self.program.clone(); let current_label = self.common.label_input.read(cx).value().to_string(); - let old_display_label = ShellProgramSelectItem::new(old_program.clone()).title(); + let old_display_label = shell_program_title(old_program.as_ref()); let program: SharedString = program.to_string().into(); self.program = program.clone(); - if !self - .program_options - .iter() - .any(|p| p.program.as_ref() == program.as_ref()) - { - self.program_options - .push(ShellProgramSelectItem::new(program.clone())); - let items = SearchableVec::new(self.program_options.clone()); - self.program_select.update(cx, |select, cx| { - select.set_items(items, window, cx); - }); - } - - self.program_select.update(cx, |select, cx| { - select.set_selected_value(&program, window, cx); - }); - // Keep the label in sync with the selected shell program, but don't override // user-customized labels. let should_update_label = { @@ -1052,7 +1010,7 @@ impl ShellSessionState { label.is_empty() || label == old_display_label.as_ref() || label == old_program.as_ref() }; if should_update_label { - let new_label = ShellProgramSelectItem::new(program).title(); + let new_label = shell_program_title(program.as_ref()); self.common.label_input.update(cx, move |input, cx| { input.set_value(new_label.clone(), window, cx); }); @@ -1153,7 +1111,6 @@ impl SshSessionState { port_input, password_input, password_edit_unlocked: true, - sftp: true, tcp_nodelay: true, tcp_keepalive: false, diff --git a/termua/src/window/new_session/render.rs b/termua/src/window/new_session/render.rs index 2235199..7c24d46 100644 --- a/termua/src/window/new_session/render.rs +++ b/termua/src/window/new_session/render.rs @@ -560,14 +560,6 @@ impl ShellSessionState { v_flex() .id("termua-new-session-shell-session") .gap_3() - .child(render_form_row( - t!("NewSession.Field.Shell").to_string(), - div() - .w_full() - .debug_selector(|| "termua-new-session-shell-program-select".to_string()) - .child(Select::new(&self.program_select)), - cx, - )) .child(render_form_row( t!("NewSession.Field.Type").to_string(), div() @@ -1063,7 +1055,6 @@ impl SshSessionState { view: Entity, cx: &mut Context, ) -> Vec { - let view_sftp = view.clone(); let env_editor = self.render_env_editor(view, cx); vec![ render_form_row( @@ -1128,20 +1119,6 @@ impl SshSessionState { cx, ) .into_any_element(), - render_form_row( - t!("NewSession.Ssh.Field.Sftp").to_string(), - Switch::new("termua-new-session-ssh-sftp") - .checked(self.sftp) - .on_click(move |checked, window, app| { - view_sftp.update(app, |this, cx| { - this.ssh.sftp = *checked; - cx.notify(); - }); - window.refresh(); - }), - cx, - ) - .into_any_element(), ] } diff --git a/termua/src/window/new_session/state.rs b/termua/src/window/new_session/state.rs index fcfe435..ed2a76d 100644 --- a/termua/src/window/new_session/state.rs +++ b/termua/src/window/new_session/state.rs @@ -1,10 +1,10 @@ use gpui::{ App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, StyledImage, - Window, div, img, prelude::FluentBuilder, px, + Window, div, img, px, }; use gpui_common::TermuaIcon; use gpui_component::{ - Icon, h_flex, + h_flex, input::InputState, select::{SearchableVec, SelectItem, SelectState}, }; @@ -47,115 +47,15 @@ pub(super) struct SessionCommonState { pub(super) struct ShellSessionState { pub(super) program: SharedString, - pub(super) program_options: Vec, - pub(super) program_select: Entity>>, pub(super) env_rows: Vec, pub(super) env_next_id: u64, pub(super) common: SessionCommonState, } -#[derive(Clone)] -pub(super) struct ShellProgramSelectItem { - pub(super) program: SharedString, - label: SharedString, -} - -impl ShellProgramSelectItem { - pub(super) fn new(program: SharedString) -> Self { - let label = match program.as_ref() { - "nu" => "nushell".to_string(), - "pwsh" => "powershell".to_string(), - _ => program.to_string(), - }; - - Self { - program, - label: SharedString::from(label), - } - } - - pub(super) fn icon_path(&self) -> Option { - match self.program.as_ref() { - "sh" => Some(TermuaIcon::Sh), - "bash" | "zsh" => Some(TermuaIcon::Terminal), - "nu" | "nushell" => Some(TermuaIcon::Nushell), - "pwsh" | "powershell" => Some(TermuaIcon::Pwsh), - "fish" => Some(TermuaIcon::Fish), - _ => None, - } - } - - pub(super) fn uses_themed_icon(&self) -> bool { - !matches!(self.program.as_ref(), "pwsh" | "powershell") && self.icon_path().is_some() - } - - fn icon_element(&self) -> Option { - let path = self.icon_path()?; - if self.uses_themed_icon() { - Some(Icon::default().path(path).size_4().into_any_element()) - } else { - Some( - img(path) - .w(px(16.)) - .h(px(16.)) - .flex_shrink_0() - .object_fit(gpui::ObjectFit::Contain) - .into_any_element(), - ) - } - } -} - -impl gpui_component::select::SelectItem for ShellProgramSelectItem { - type Value = SharedString; - - fn title(&self) -> SharedString { - self.label.clone() - } - - fn display_title(&self) -> Option { - // The selected value shown in the input should be left-aligned; do not reserve icon - // space unless the item actually has an icon. - let icon = self.icon_element(); - - Some( - h_flex() - .w_full() - .justify_start() - .items_center() - .gap_2() - .debug_selector(|| "termua-new-session-shell-program-display-title".to_string()) - .when_some(icon, |this, icon| this.child(icon)) - .child(self.label.clone()) - .into_any_element(), - ) - } - - fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { - let icon = if let Some(icon) = self.icon_element() { - icon - } else { - div() - .w(px(16.)) - .h(px(16.)) - .flex_shrink_0() - .into_any_element() - }; - - h_flex() - .items_center() - .gap_2() - .child(icon) - .child(self.label.clone()) - } - - fn value(&self) -> &Self::Value { - &self.program - } - - fn matches(&self, query: &str) -> bool { - let query = query.to_lowercase(); - self.label.to_lowercase().contains(&query) || self.program.to_lowercase().contains(&query) +pub(super) fn shell_program_title(program: &str) -> SharedString { + match program { + "pwsh" => SharedString::from("powershell"), + other => SharedString::from(other.to_string()), } } @@ -170,7 +70,6 @@ pub(super) struct SshSessionState { pub(super) port_input: Entity, pub(super) password_input: Entity, pub(super) password_edit_unlocked: bool, - pub(super) sftp: bool, pub(super) tcp_nodelay: bool, pub(super) tcp_keepalive: bool, diff --git a/termua/src/window/new_session/tests.rs b/termua/src/window/new_session/tests.rs index 7e18e42..697715b 100644 --- a/termua/src/window/new_session/tests.rs +++ b/termua/src/window/new_session/tests.rs @@ -1,5 +1,4 @@ use gpui::{ParentElement, Render, Styled, div}; -use gpui_common::TermuaIcon; use super::*; use crate::{env::build_terminal_env, store::SessionEnvVar}; @@ -1318,72 +1317,6 @@ fn new_session_type_select_is_left_aligned(cx: &mut gpui::TestAppContext) { ); } -#[gpui::test] -fn new_session_shell_program_control_uses_select_component(cx: &mut gpui::TestAppContext) { - cx.update(|app| { - menubar::init(app); - gpui_term::init(app); - }); - - let shell = cx.add_empty_window(); - shell.draw( - gpui::point(gpui::px(0.), gpui::px(0.)), - gpui::size( - gpui::AvailableSpace::Definite(gpui::px(800.)), - gpui::AvailableSpace::Definite(gpui::px(600.)), - ), - |window, app| { - let view = app.new(|cx| NewSessionWindow::new(window, cx)); - div().size_full().child(view) - }, - ); - shell.run_until_parked(); - assert!( - shell - .debug_bounds("termua-new-session-shell-program-select") - .is_some() - ); - assert!( - shell - .debug_bounds("termua-new-session-shell-program") - .is_none() - ); -} - -#[gpui::test] -fn new_session_shell_program_select_value_is_left_aligned(cx: &mut gpui::TestAppContext) { - cx.update(|app| { - menubar::init(app); - gpui_term::init(app); - }); - - let shell = cx.add_empty_window(); - shell.draw( - gpui::point(gpui::px(0.), gpui::px(0.)), - gpui::size( - gpui::AvailableSpace::Definite(gpui::px(800.)), - gpui::AvailableSpace::Definite(gpui::px(600.)), - ), - |window, app| { - let view = app.new(|cx| NewSessionWindow::new(window, cx)); - div().size_full().child(view) - }, - ); - shell.run_until_parked(); - - let select_bounds = shell - .debug_bounds("termua-new-session-shell-program-select") - .expect("shell program select should exist"); - let content_bounds = shell - .debug_bounds("termua-new-session-shell-program-display-title") - .expect("shell program display title should exist"); - let left_gap = content_bounds.left() - select_bounds.left(); - assert!( - left_gap <= gpui::px(60.0), - "expected shell program select value to be left-aligned" - ); -} - #[gpui::test] fn new_session_shell_label_follows_shell_program(cx: &mut gpui::TestAppContext) { use std::sync::{Arc, Mutex}; @@ -1463,7 +1396,6 @@ fn edit_session_does_not_render_connect_button(cx: &mut gpui::TestAppContext) { label: "prod".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm-256color", "UTF-8", None), - shell_program: None, ssh_host: Some("example.com".to_string()), ssh_port: Some(22), ssh_auth_type: Some(crate::store::SshAuthType::Password), @@ -1523,7 +1455,6 @@ fn edit_session_disables_protocol_switching(cx: &mut gpui::TestAppContext) { label: "prod".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm-256color", "UTF-8", None), - shell_program: None, ssh_host: Some("example.com".to_string()), ssh_port: Some(22), ssh_auth_type: Some(crate::store::SshAuthType::Password), @@ -1598,7 +1529,6 @@ fn edit_session_password_input_is_locked_until_explicitly_edited(cx: &mut gpui:: label: "prod".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm-256color", "UTF-8", None), - shell_program: None, ssh_host: Some("example.com".to_string()), ssh_port: Some(22), ssh_auth_type: Some(crate::store::SshAuthType::Password), @@ -1677,7 +1607,6 @@ fn edit_session_hides_reserved_terminal_env_rows(cx: &mut gpui::TestAppContext) value: "bar".to_string(), }, ]), - shell_program: None, ssh_host: Some("example.com".to_string()), ssh_port: Some(22), ssh_auth_type: Some(crate::store::SshAuthType::Password), @@ -1805,45 +1734,6 @@ fn local_terminal_env_includes_shell_term_and_locale() { assert_eq!(env.get("LANG"), Some(&"C".to_string())); } -#[test] -fn shell_program_select_item_shows_full_name_and_shell_icons() { - let nu = ShellProgramSelectItem::new("nu".into()); - assert_eq!(nu.title().as_ref(), "nushell"); - assert_eq!(nu.icon_path(), Some(TermuaIcon::Nushell)); - - let pwsh = ShellProgramSelectItem::new("pwsh".into()); - assert_eq!(pwsh.title().as_ref(), "powershell"); - assert_eq!(pwsh.icon_path(), Some(TermuaIcon::Pwsh)); - - let powershell = ShellProgramSelectItem::new("powershell".into()); - assert_eq!(powershell.title().as_ref(), "powershell"); - assert_eq!(powershell.icon_path(), Some(TermuaIcon::Pwsh)); - - let bash = ShellProgramSelectItem::new("bash".into()); - assert_eq!(bash.title().as_ref(), "bash"); - assert_eq!(bash.icon_path(), Some(TermuaIcon::Terminal)); - - let sh = ShellProgramSelectItem::new("sh".into()); - assert_eq!(sh.title().as_ref(), "sh"); - assert_eq!(sh.icon_path(), Some(TermuaIcon::Sh)); - - let zsh = ShellProgramSelectItem::new("zsh".into()); - assert_eq!(zsh.title().as_ref(), "zsh"); - assert_eq!(zsh.icon_path(), Some(TermuaIcon::Terminal)); -} - -#[test] -fn shell_program_select_item_uses_themed_icon_renderer() { - let bash = ShellProgramSelectItem::new("bash".into()); - assert!(bash.uses_themed_icon()); - - let nu = ShellProgramSelectItem::new("nu".into()); - assert!(nu.uses_themed_icon()); - - let pwsh = ShellProgramSelectItem::new("pwsh".into()); - assert!(!pwsh.uses_themed_icon()); -} - #[test] fn edit_mode_disabled_protocol_tab_uses_not_allowed_cursor() { let selected_ix = Protocol::Ssh.tab_index(); @@ -1879,16 +1769,7 @@ fn new_local_connect_persists_session_in_store(cx: &mut gpui::TestAppContext) { gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-new-session-local-persist-{}-{}", - 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 db_path = crate::store::tests::unique_test_db_path("new-session-local-persist"); let _guard = crate::store::tests::override_termua_db_path(db_path); let win = cx.add_empty_window(); @@ -1914,12 +1795,9 @@ fn new_local_connect_persists_session_in_store(cx: &mut gpui::TestAppContext) { .unwrap() .clone() .expect("expected view to be captured"); - let (expected_label, expected_shell_program) = win.update(|_window, app| { + let expected_label = win.update(|_window, app| { let view = view.read(app); - ( - view.shell.common.label_input.read(app).value().to_string(), - view.shell.program.to_string(), - ) + view.shell.common.label_input.read(app).value().to_string() }); win.update(|_window, app| { @@ -1937,10 +1815,6 @@ fn new_local_connect_persists_session_in_store(cx: &mut gpui::TestAppContext) { assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].group_path, "local"); assert_eq!(sessions[0].label, expected_label); - assert_eq!( - sessions[0].shell_program.as_deref(), - Some(expected_shell_program.as_str()) - ); } #[gpui::test] @@ -1953,16 +1827,7 @@ fn new_local_connect_persists_colorterm_and_env_in_store(cx: &mut gpui::TestAppC gpui_component::init(app); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-new-session-local-env-persist-{}-{}", - 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 db_path = crate::store::tests::unique_test_db_path("new-session-local-env-persist"); let _guard = crate::store::tests::override_termua_db_path(db_path); let win = cx.add_empty_window(); @@ -2068,16 +1933,7 @@ fn new_local_connect_with_empty_label_and_group_enqueues_sidebar_reload_after_pe app.set_global(crate::TermuaAppState::default()); }); - let tmp_dir = std::env::temp_dir().join(format!( - "termua-new-session-local-reload-{}-{}", - 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 db_path = crate::store::tests::unique_test_db_path("new-session-local-reload"); let _guard = crate::store::tests::override_termua_db_path(db_path); let (_root, main_window_cx) = cx.add_window_view(|window, cx| { @@ -2235,7 +2091,6 @@ fn edit_session_repeat_save_is_ignored_while_submit_is_in_flight(cx: &mut gpui:: label: "prod".to_string(), backend: crate::settings::TerminalBackend::Wezterm, env: test_session_env("xterm-256color", "UTF-8", None), - shell_program: None, ssh_host: Some("example.com".to_string()), ssh_port: Some(22), ssh_auth_type: Some(crate::store::SshAuthType::Password),