From f307bbfd4e05506c489e1be13f58087a28b1e9fc Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 2 Aug 2026 16:49:04 +0800 Subject: [PATCH 1/8] feat: support to select shell type --- assets/icons/fish.svg | 1 + assets/icons/nushell.svg | 1 + assets/icons/pwsh.svg | 4 +- crates/gpui_common/src/assets.rs | 2 + crates/gpui_term/src/shell.rs | 200 +++++++++++++++- locales/en.yml | 2 +- locales/zh-CN.yml | 2 +- termua/src/panel/sessions_sidebar/icons.rs | 51 +++- termua/src/panel/sessions_sidebar/tests.rs | 19 +- termua/src/panel/ssh_error_panel.rs | 38 ++- termua/src/panel/terminal_panel.rs | 83 ++++++- termua/src/window/main_window/actions/ssh.rs | 3 +- .../window/main_window/actions/terminal.rs | 83 ++++++- termua/src/window/main_window/tests.rs | 19 ++ termua/src/window/new_session/actions.rs | 62 +++-- termua/src/window/new_session/mod.rs | 171 ++++++++++++-- termua/src/window/new_session/render.rs | 66 ++++-- termua/src/window/new_session/state.rs | 131 ++++++++++- termua/src/window/new_session/tests.rs | 221 ++++++++++++++++-- 19 files changed, 1038 insertions(+), 121 deletions(-) create mode 100644 assets/icons/fish.svg create mode 100644 assets/icons/nushell.svg diff --git a/assets/icons/fish.svg b/assets/icons/fish.svg new file mode 100644 index 0000000..6a1aeb1 --- /dev/null +++ b/assets/icons/fish.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/nushell.svg b/assets/icons/nushell.svg new file mode 100644 index 0000000..c87aa47 --- /dev/null +++ b/assets/icons/nushell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pwsh.svg b/assets/icons/pwsh.svg index 2c261bd..dd5ee82 100644 --- a/assets/icons/pwsh.svg +++ b/assets/icons/pwsh.svg @@ -1 +1,3 @@ -file_type_powershell + + + diff --git a/crates/gpui_common/src/assets.rs b/crates/gpui_common/src/assets.rs index f198f1c..7bf3245 100644 --- a/crates/gpui_common/src/assets.rs +++ b/crates/gpui_common/src/assets.rs @@ -125,6 +125,8 @@ mod tests { TermuaIcon::FolderOpenBlue, TermuaIcon::FolderClosedBlue, TermuaIcon::GitBash, + TermuaIcon::Fish, + TermuaIcon::Nushell, TermuaIcon::Pwsh, ] { assert!( diff --git a/crates/gpui_term/src/shell.rs b/crates/gpui_term/src/shell.rs index 96e6d8e..9da67d9 100644 --- a/crates/gpui_term/src/shell.rs +++ b/crates/gpui_term/src/shell.rs @@ -7,6 +7,8 @@ pub const TERMUA_SHELL_ENV_KEY: &str = "TERMUA_SHELL"; pub enum ShellKind { Bash, Zsh, + Fish, + Nu, Pwsh, PowerShell, Cmd, @@ -34,19 +36,16 @@ pub fn pick_shell_program_from_env_or_else( } pub fn shell_kind(program: &str) -> ShellKind { - let program = program.trim(); - if program.is_empty() { + let name = normalized_shell_program_name(program); + if name.is_empty() { return ShellKind::Other; } - let name = std::path::Path::new(program) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(program); - - match name { + match name.as_str() { "bash" => ShellKind::Bash, "zsh" => ShellKind::Zsh, + "fish" => ShellKind::Fish, + "nu" => ShellKind::Nu, "pwsh" => ShellKind::Pwsh, "powershell" => ShellKind::PowerShell, "cmd" => ShellKind::Cmd, @@ -58,6 +57,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::Pwsh | ShellKind::PowerShell => "powershell".to_string(), ShellKind::Cmd => "cmd".to_string(), ShellKind::Other => std::path::Path::new(program.trim()) @@ -70,17 +71,89 @@ pub fn shell_display_name(program: &str) -> String { pub fn shell_program_candidates() -> &'static [&'static str] { if cfg!(windows) { - // Windows: prefer PowerShell 7+ when available, then Windows PowerShell, then cmd. - &["pwsh", "powershell", "cmd"] + // Windows: prefer PowerShell 7+, Windows PowerShell, and cmd; also detect NuShell. + &["pwsh", "powershell", "cmd", "nu"] } else if cfg!(target_os = "macos") { // macOS: default user shell is zsh on modern macOS. - &["zsh", "bash", "pwsh"] + &["zsh", "bash", "fish", "nu", "pwsh", "powershell"] } else { // Linux/*nix: bash is commonly available and expected. - &["bash", "zsh", "pwsh"] + &["bash", "zsh", "fish", "nu", "pwsh", "powershell"] } } +fn detect_shell_programs( + process_shell: Option<&str>, + exists: impl Fn(&str) -> bool, +) -> Vec { + detect_shell_programs_from_candidates(process_shell, shell_program_candidates(), exists) +} + +fn normalized_shell_program_name(program: &str) -> String { + let name = program + .trim() + .rsplit(['/', '\\']) + .next() + .unwrap_or_default(); + let name = name.to_ascii_lowercase(); + name.strip_suffix(".exe").unwrap_or(&name).to_string() +} + +fn detect_shell_programs_from_candidates( + process_shell: Option<&str>, + candidates: &[&str], + exists: impl Fn(&str) -> bool, +) -> Vec { + let process_shell = process_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()); + let process_shell_name = process_shell.map(normalized_shell_program_name); + let process_shell_available = process_shell.is_some_and(&exists); + let candidate_states = candidates + .iter() + .map(|candidate| { + let normalized_name = normalized_shell_program_name(candidate); + let duplicates_process_shell = process_shell_available + && process_shell_name.as_deref() == Some(normalized_name.as_str()); + let available = !duplicates_process_shell && exists(candidate); + (*candidate, normalized_name, available) + }) + .collect::>(); + let pwsh_available = (process_shell_available && process_shell_name.as_deref() == Some("pwsh")) + || candidate_states + .iter() + .any(|(_, name, available)| *available && name == "pwsh"); + + let mut programs = Vec::new(); + if let Some(shell) = process_shell.filter(|_| { + process_shell_available + && !(pwsh_available && process_shell_name.as_deref() == Some("powershell")) + }) { + programs.push(shell.to_string()); + } + + for (candidate, candidate_name, available) in candidate_states { + if (process_shell_available + && process_shell_name.as_deref() == Some(candidate_name.as_str())) + || (pwsh_available && candidate_name == "powershell") + || !available + { + continue; + } + programs.push(candidate.to_string()); + } + + if programs.is_empty() { + programs.push(default_shell_program().to_string()); + } + programs +} + +pub fn available_shell_programs() -> Vec { + let process_shell = std::env::var(SHELL_ENV_KEY).ok(); + detect_shell_programs(process_shell.as_deref(), program_exists_on_path) +} + pub fn default_shell_program() -> &'static str { if cfg!(windows) { "pwsh" @@ -190,15 +263,29 @@ 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::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_kind_normalizes_executable_suffix_case_and_windows_paths() { + assert_eq!(shell_kind("/opt/nushell/bin/nu.exe"), ShellKind::Nu); + assert_eq!(shell_kind("PWSh.EXE"), ShellKind::Pwsh); + assert_eq!( + shell_kind(r"C:\Program Files\PowerShell\7\pwsh.exe"), + ShellKind::Pwsh + ); + } + #[test] fn shell_display_name_normalizes_supported_shells() { assert_eq!(shell_display_name("/bin/bash"), "bash"); + assert_eq!(shell_display_name("fish"), "fish"); + assert_eq!(shell_display_name("nu"), "nushell"); assert_eq!(shell_display_name("pwsh"), "powershell"); assert_eq!(shell_display_name("powershell"), "powershell"); } @@ -219,6 +306,10 @@ mod tests { fn platform_shell_candidates_are_ordered_by_preference() { let candidates = shell_program_candidates(); + assert!(candidates.contains(&"nu")); + assert!(candidates.contains(&"pwsh")); + assert!(candidates.contains(&"powershell")); + #[cfg(windows)] assert_eq!(candidates.first().copied(), Some("pwsh")); @@ -229,6 +320,91 @@ mod tests { assert_eq!(candidates.first().copied(), Some("bash")); } + #[test] + fn detected_shell_programs_filters_unavailable_candidates() { + let detected = detect_shell_programs(None, |program| matches!(program, "zsh" | "nu")); + + assert_eq!(detected, vec!["zsh".to_string(), "nu".to_string()]); + } + + #[test] + fn detected_shell_programs_prefers_executable_process_shell() { + let detected = detect_shell_programs(Some("/opt/homebrew/bin/fish"), |program| { + matches!(program, "/opt/homebrew/bin/fish" | "bash" | "fish") + }); + + assert_eq!( + detected.first().map(String::as_str), + Some("/opt/homebrew/bin/fish") + ); + assert_eq!( + detected + .iter() + .filter(|program| program.ends_with("fish")) + .count(), + 1 + ); + } + + #[test] + fn detected_shell_programs_prefers_power_shell_7_when_both_versions_exist() { + let detected = + detect_shell_programs_from_candidates(None, &["pwsh", "powershell", "nu"], |_| true); + + assert_eq!(detected, vec!["pwsh".to_string(), "nu".to_string()]); + } + + #[test] + fn detected_shell_programs_does_not_probe_a_candidate_more_than_once() { + use std::{cell::RefCell, collections::HashMap}; + + let probe_counts = RefCell::new(HashMap::::new()); + let detected = + detect_shell_programs_from_candidates(None, &["pwsh", "powershell", "nu"], |program| { + *probe_counts + .borrow_mut() + .entry(program.to_string()) + .or_default() += 1; + true + }); + + assert_eq!(detected, vec!["pwsh".to_string(), "nu".to_string()]); + assert!( + probe_counts.borrow().values().all(|count| *count == 1), + "each PATH candidate should be probed at most once: {:?}", + probe_counts.borrow() + ); + } + + #[test] + fn detected_shell_programs_keeps_power_shell_5_when_version_7_is_missing() { + let detected = + detect_shell_programs_from_candidates(None, &["pwsh", "powershell"], |program| { + program == "powershell" + }); + + assert_eq!(detected, vec!["powershell".to_string()]); + } + + #[test] + fn detected_shell_programs_drops_power_shell_5_process_shell_when_version_7_exists() { + let detected = detect_shell_programs_from_candidates( + Some("/opt/microsoft/powershell"), + &["pwsh", "powershell"], + |program| matches!(program, "/opt/microsoft/powershell" | "pwsh" | "powershell"), + ); + + assert_eq!(detected, vec!["pwsh".to_string()]); + } + + #[test] + fn detected_shell_programs_falls_back_when_none_are_available() { + assert_eq!( + detect_shell_programs(Some("/missing/shell"), |_| false), + vec![default_shell_program().to_string()] + ); + } + #[test] fn split_pathext_ignores_empty_segments() { assert_eq!( diff --git a/locales/en.yml b/locales/en.yml index 9ec1847..398a50c 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -69,7 +69,7 @@ NewSession: Host: "Host:" Port: "Port:" Hint: - ReservedTerminalEnv: "TERM, COLORTERM, and CHARSET are managed by the fields above." + ReservedTerminalEnv: "TERM, COLORTERM, CHARSET, SHELL, and TERMUA_SHELL are managed by the fields above." Nav: Session: "Session" Ssh: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index eec8ffd..12268b6 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -68,7 +68,7 @@ NewSession: Host: "主机:" Port: "端口:" Hint: - ReservedTerminalEnv: "TERM、COLORTERM 和 CHARSET 由上方字段管理。" + ReservedTerminalEnv: "TERM、COLORTERM、CHARSET、SHELL 和 TERMUA_SHELL 由上方字段管理。" Nav: Session: "会话" Ssh: diff --git a/termua/src/panel/sessions_sidebar/icons.rs b/termua/src/panel/sessions_sidebar/icons.rs index bba3ca2..9e3baac 100644 --- a/termua/src/panel/sessions_sidebar/icons.rs +++ b/termua/src/panel/sessions_sidebar/icons.rs @@ -9,15 +9,22 @@ use crate::store::{Session, SessionType}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum SessionIconKind { Terminal, + Fish, + Nushell, + Pwsh, } impl SessionIconKind { fn icon_path(self) -> TermuaIcon { - TermuaIcon::Terminal + match self { + Self::Terminal => TermuaIcon::Terminal, + Self::Fish => TermuaIcon::Fish, + Self::Nushell => TermuaIcon::Nushell, + Self::Pwsh => TermuaIcon::Pwsh, + } } pub(super) fn into_element_for_session_id(self, session_id: i64) -> AnyElement { - let _ = self; div() .w(px(16.)) .h(px(16.)) @@ -28,6 +35,15 @@ impl SessionIconKind { } } +fn icon_kind_for_shell_program(program: Option<&str>) -> SessionIconKind { + match crate::panel::terminal_panel::shell_icon_for_program(program) { + TermuaIcon::Fish => SessionIconKind::Fish, + TermuaIcon::Nushell => SessionIconKind::Nushell, + TermuaIcon::Pwsh => SessionIconKind::Pwsh, + _ => SessionIconKind::Terminal, + } +} + pub(super) fn build_session_icon_kinds(sessions: &[Session]) -> BTreeMap { let mut out = BTreeMap::new(); for session in sessions { @@ -35,7 +51,36 @@ pub(super) fn build_session_icon_kinds(sessions: &[Session]) -> BTreeMap usize { + self.id + } + pub(crate) fn new( id: usize, tab_label: SharedString, @@ -77,6 +81,13 @@ impl SshErrorPanel { self.terminal_state.clone() } + pub(crate) fn local_shell_display_name(&self) -> Option { + let TerminalLaunchState::Local { env, .. } = &self.terminal_state.as_ref()?.launch else { + return None; + }; + crate::panel::terminal_panel::local_shell_display_name_from_env(env) + } + pub(crate) fn parent_tab(&self) -> Option> { self.parent_tab.clone() } @@ -117,7 +128,10 @@ impl Panel for SshErrorPanel { TerminalLaunchState::Serial { .. } => PanelKind::Serial, TerminalLaunchState::Recorder { .. } => PanelKind::Recorder, }; - return Some(tab_icon_for_terminal_panel(kind)); + return Some(tab_icon_for_terminal_panel_with_launch( + kind, + Some(&state.launch), + )); } Some(gpui_dock::TabIcon::Monochrome { @@ -228,6 +242,26 @@ mod tests { ); } + #[gpui::test] + fn restoring_powershell_panel_uses_powershell_terminal_icon(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SshErrorPanel::restoring( + terminal_state(TerminalLaunchState::Local { + backend_type: gpui_term::TerminalType::WezTerm, + env: HashMap::from([("TERMUA_SHELL".to_string(), "pwsh".to_string())]), + }), + "Restoring...".into(), + cx, + ) + }); + + assert!(matches!( + panel.read_with(cx, |panel, app| panel.tab_icon(app)), + Some(gpui_dock::TabIcon::Monochrome { path, color: None }) + if path.as_ref() == TermuaIcon::Pwsh.path() + )); + } + #[gpui::test] fn restoring_panels_use_their_terminal_icons(cx: &mut gpui::TestAppContext) { let cases = [ diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs index 332492b..7bf9958 100644 --- a/termua/src/panel/terminal_panel.rs +++ b/termua/src/panel/terminal_panel.rs @@ -101,10 +101,7 @@ pub(crate) fn local_terminal_panel_tab_name( id: usize, counts: &mut HashMap, ) -> SharedString { - let Some(base) = gpui_term::shell::pick_shell_program_from_env(env) - .map(gpui_term::shell::shell_display_name) - .filter(|name| !name.trim().is_empty()) - else { + let Some(base) = local_shell_display_name_from_env(env) else { return terminal_panel_tab_name(PanelKind::Local, id); }; @@ -114,7 +111,24 @@ pub(crate) fn local_terminal_panel_tab_name( if *count == 1 { base.into() } else { - format!("{base} {id}").into() + format!("{base} {count}").into() + } +} + +pub(crate) fn local_shell_display_name_from_env(env: &HashMap) -> Option { + gpui_term::shell::pick_shell_program_from_env(env) + .map(gpui_term::shell::shell_display_name) + .filter(|name| !name.trim().is_empty()) +} + +pub(crate) fn shell_icon_for_program(program: Option<&str>) -> TermuaIcon { + match program.map(gpui_term::shell::shell_kind) { + Some(gpui_term::shell::ShellKind::Fish) => TermuaIcon::Fish, + Some(gpui_term::shell::ShellKind::Nu) => TermuaIcon::Nushell, + Some(gpui_term::shell::ShellKind::Pwsh | gpui_term::shell::ShellKind::PowerShell) => { + TermuaIcon::Pwsh + } + _ => TermuaIcon::Terminal, } } @@ -139,6 +153,26 @@ pub(crate) fn tab_icon_for_terminal_panel(kind: PanelKind) -> gpui_dock::TabIcon } } +pub(crate) fn tab_icon_for_terminal_panel_with_launch( + kind: PanelKind, + launch_state: Option<&TerminalLaunchState>, +) -> gpui_dock::TabIcon { + if kind == PanelKind::Local { + let program = match launch_state { + Some(TerminalLaunchState::Local { env, .. }) => { + gpui_term::shell::pick_shell_program_from_env(env) + } + _ => None, + }; + return gpui_dock::TabIcon::Monochrome { + path: shell_icon_for_program(program).into(), + color: None, + }; + } + + tab_icon_for_terminal_panel(kind) +} + pub(crate) struct TerminalPanel { id: usize, kind: PanelKind, @@ -196,6 +230,13 @@ impl TerminalPanel { self.tab_label.clone() } + pub(crate) fn local_shell_display_name(&self) -> Option { + match self.launch_state.as_ref() { + Some(TerminalLaunchState::Local { env, .. }) => local_shell_display_name_from_env(env), + _ => None, + } + } + pub(crate) fn cleanup_runtime_state(id: usize, cx: &mut Context) { crate::assistant::unregister_terminal_target(cx, id); crate::footbar::blur_terminal_backend(id, cx); @@ -519,7 +560,10 @@ impl Panel for TerminalPanel { } fn tab_icon(&self, _cx: &App) -> Option { - Some(tab_icon_for_terminal_panel(self.kind)) + Some(tab_icon_for_terminal_panel_with_launch( + self.kind, + self.launch_state.as_ref(), + )) } fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context) { @@ -649,6 +693,29 @@ mod tests { )); } + #[test] + fn local_terminal_tab_icons_follow_shell_program() { + for (program, expected_icon) in [ + ("bash", TermuaIcon::Terminal), + ("zsh", TermuaIcon::Terminal), + ("fish", TermuaIcon::Fish), + ("nu", TermuaIcon::Nushell), + ("pwsh", TermuaIcon::Pwsh), + ("powershell", TermuaIcon::Pwsh), + ("cmd", TermuaIcon::Terminal), + ] { + let launch = TerminalLaunchState::Local { + backend_type: gpui_term::TerminalType::WezTerm, + env: HashMap::from([("TERMUA_SHELL".to_string(), program.to_string())]), + }; + assert!(matches!( + tab_icon_for_terminal_panel_with_launch(PanelKind::Local, Some(&launch)), + gpui_dock::TabIcon::Monochrome { path, color: None } + if path.as_ref() == expected_icon.path() + )); + } + } + #[test] fn recorder_tabs_use_recorder_prefix() { assert_eq!( @@ -691,7 +758,7 @@ mod tests { } #[test] - fn duplicate_local_shell_tabs_append_terminal_id() { + fn duplicate_local_shell_tabs_append_shell_sequence() { let mut counts = HashMap::new(); let mut env = HashMap::new(); env.insert("TERMUA_SHELL".into(), "bash".into()); @@ -702,7 +769,7 @@ mod tests { ); assert_eq!( local_terminal_panel_tab_name(&env, 9, &mut counts).as_ref(), - "bash 9" + "bash 2" ); } diff --git a/termua/src/window/main_window/actions/ssh.rs b/termua/src/window/main_window/actions/ssh.rs index fe1e73e..c41bd20 100644 --- a/termua/src/window/main_window/actions/ssh.rs +++ b/termua/src/window/main_window/actions/ssh.rs @@ -570,8 +570,7 @@ impl TermuaWindow { return; } - let id = self.next_terminal_id; - self.next_terminal_id += 1; + let id = self.take_next_terminal_id(cx); let tab_label = dedupe_tab_label(&mut self.ssh_tab_label_counts, params.name.as_str()); diff --git a/termua/src/window/main_window/actions/terminal.rs b/termua/src/window/main_window/actions/terminal.rs index b193a88..bf724a9 100644 --- a/termua/src/window/main_window/actions/terminal.rs +++ b/termua/src/window/main_window/actions/terminal.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use gpui::{AppContext, Context, FocusHandle, ReadGlobal, SharedString, Window}; use gpui_dock::{DockPlacement, PanelView}; @@ -17,6 +20,72 @@ use crate::{ }; impl TermuaWindow { + fn occupied_terminal_ids(&self, cx: &gpui::App) -> HashSet { + self.dock_area + .read(cx) + .all_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .filter_map(|panel| { + if let Ok(panel) = panel.view().downcast::() { + return Some(panel.read(cx).id()); + } + panel + .view() + .downcast::() + .ok() + .map(|panel| panel.read(cx).id()) + }) + .collect() + } + + pub(in crate::window::main_window) fn take_next_terminal_id( + &mut self, + cx: &gpui::App, + ) -> usize { + let occupied = self.occupied_terminal_ids(cx); + + while occupied.contains(&self.next_terminal_id) { + self.next_terminal_id = self.next_terminal_id.saturating_add(1); + } + let id = self.next_terminal_id; + self.next_terminal_id = self.next_terminal_id.saturating_add(1); + id + } + + fn reset_next_terminal_id(&mut self, cx: &gpui::App) { + let occupied = self.occupied_terminal_ids(cx); + self.next_terminal_id = 1; + while occupied.contains(&self.next_terminal_id) { + self.next_terminal_id = self.next_terminal_id.saturating_add(1); + } + } + + fn rebuild_local_tab_label_counts(&mut self, cx: &gpui::App) { + self.local_tab_label_counts.clear(); + let shell_names = self + .dock_area + .read(cx) + .all_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .filter_map(|panel| { + if let Ok(panel) = panel.view().downcast::() { + return panel.read(cx).local_shell_display_name(); + } + panel + .view() + .downcast::() + .ok() + .and_then(|panel| panel.read(cx).local_shell_display_name()) + }) + .collect::>(); + + for shell_name in shell_names { + *self.local_tab_label_counts.entry(shell_name).or_default() += 1; + } + } + 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); } @@ -471,8 +540,7 @@ impl TermuaWindow { window: &mut Window, cx: &mut Context, ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; + let id = self.take_next_terminal_id(cx); let tab_label = dedupe_tab_label(&mut self.ssh_tab_label_counts, name.as_str()); let tab_tooltip = ssh_tab_tooltip(&opts); @@ -499,8 +567,7 @@ impl TermuaWindow { window: &mut Window, cx: &mut Context, ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; + let id = self.take_next_terminal_id(cx); let tab_label = terminal_panel_tab_name(PanelKind::Serial, id); let tab_tooltip: SharedString = format!("{name}\n{} @ {}", opts.port, opts.baud).into(); @@ -527,8 +594,7 @@ impl TermuaWindow { window: &mut Window, cx: &mut Context, ) -> gpui::Entity { - let id = self.next_terminal_id; - self.next_terminal_id += 1; + let id = self.take_next_terminal_id(cx); let tab_label = match kind { PanelKind::Local => crate::panel::local_terminal_panel_tab_name( @@ -579,7 +645,6 @@ impl TermuaWindow { let panel = panel.read(cx); (panel.id(), panel.tab_label(), panel.terminal_view()) }; - self.next_terminal_id = self.next_terminal_id.max(id.saturating_add(1)); let terminal = terminal_view.read(cx).terminal.clone(); self.subscribe_terminal_events_for_messages( terminal.clone(), @@ -598,6 +663,8 @@ impl TermuaWindow { ); self.subscribe_terminal_view_events(&terminal_view, window, cx); } + self.reset_next_terminal_id(cx); + self.rebuild_local_tab_label_counts(cx); } pub(in crate::window::main_window) fn restore_pending_sftp_panels( diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 91acb87..92b520b 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -346,6 +346,8 @@ fn main_window_restores_local_terminal_panel(cx: &mut gpui::TestAppContext) { }); first_cx.update(|window, cx| { first.update(cx, |this, cx| { + // Simulate a long-running app where many terminal IDs were previously consumed. + this.next_terminal_id = 42; let env = HashMap::from([("TERMUA_SHELL".to_string(), "sh".to_string())]); add_fake_local_terminal_with_launch( this, @@ -389,6 +391,23 @@ fn main_window_restores_local_terminal_panel(cx: &mut gpui::TestAppContext) { .is_ok(), "saved terminal panel should be rebuilt by its registered factory" ); + assert_eq!( + restored.read(cx).next_terminal_id, + 1, + "new tabs after restart should reuse the smallest available terminal ID" + ); + let next_label = restored.update(cx, |this, _cx| { + crate::panel::local_terminal_panel_tab_name( + &HashMap::from([("TERMUA_SHELL".to_string(), "sh".to_string())]), + this.next_terminal_id, + &mut this.local_tab_label_counts, + ) + }); + assert_eq!( + next_label.as_ref(), + "sh 2", + "restored local tabs should contribute to per-shell label numbering" + ); }); assert_eq!(restore_attempts.load(Ordering::SeqCst), 1); diff --git a/termua/src/window/new_session/actions.rs b/termua/src/window/new_session/actions.rs index d756136..8d04f6e 100644 --- a/termua/src/window/new_session/actions.rs +++ b/termua/src/window/new_session/actions.rs @@ -3,7 +3,7 @@ use gpui_term::{Authentication, SshOptions, TerminalType}; use super::{ DEFAULT_COLORTERM, EnvRowState, NewSessionWindow, Protocol, SshAuthType, - new_proxy_jump_row_state, set_input_value, ssh, + is_reserved_terminal_env_name, new_proxy_jump_row_state, set_input_value, ssh, }; use crate::{ SerialParams, SshParams, @@ -30,6 +30,8 @@ struct SshFormValues { const SESSION_ENV_TERM: &str = "TERM"; const SESSION_ENV_COLORTERM: &str = "COLORTERM"; const SESSION_ENV_CHARSET: &str = "CHARSET"; +const SESSION_ENV_SHELL: &str = "SHELL"; +const SESSION_ENV_TERMUA_SHELL: &str = gpui_term::shell::TERMUA_SHELL_ENV_KEY; fn session_store_env_from_fields( term: &str, @@ -46,7 +48,10 @@ fn session_store_env_from_fields( for var in vars { let name = var.name.trim(); - if name.is_empty() || is_reserved_terminal_env_name(name) { + if name.is_empty() + || is_terminal_field_env_name(name) + || name.eq_ignore_ascii_case(SESSION_ENV_SHELL) + { continue; } upsert_session_store_env(&mut env, name, var.value.clone()); @@ -85,7 +90,7 @@ fn session_store_terminal_fields_from_env( (term, colorterm, charset) } -fn is_reserved_terminal_env_name(name: &str) -> bool { +fn is_terminal_field_env_name(name: &str) -> bool { name.eq_ignore_ascii_case(SESSION_ENV_TERM) || name.eq_ignore_ascii_case(SESSION_ENV_COLORTERM) || name.eq_ignore_ascii_case(SESSION_ENV_CHARSET) @@ -453,9 +458,8 @@ impl SessionStoreOp { impl NewSessionWindow { fn read_ssh_form_values(&self, cx: &Context) -> SshFormValues { - let backend = Self::load_default_backend(); SshFormValues { - backend, + backend: self.ssh.common.backend, auth_type: self.ssh.auth_type, user_raw: self.ssh.user_input.read(cx).value().to_string(), host_raw: self.ssh.host_input.read(cx).value().to_string(), @@ -471,13 +475,6 @@ impl NewSessionWindow { } } - fn load_default_backend() -> crate::settings::TerminalBackend { - crate::settings::load_settings_from_disk() - .unwrap_or_default() - .terminal - .default_backend - } - fn backend_for_terminal_type(backend: crate::settings::TerminalBackend) -> TerminalType { match backend { crate::settings::TerminalBackend::Alacritty => TerminalType::Alacritty, @@ -512,8 +509,13 @@ impl NewSessionWindow { env } - fn shell_session_env_for_store(&self, cx: &Context) -> Vec { - Self::session_env_rows_for_store(&self.shell.env_rows, cx) + fn shell_session_env_for_store(&self, app: &App) -> Vec { + let mut env = Self::session_env_rows_for_store(&self.shell.env_rows, app); + env.push(SessionEnvVar { + name: SESSION_ENV_TERMUA_SHELL.to_string(), + value: self.shell.program.to_string(), + }); + env } fn ssh_session_env_for_store(&self, cx: &Context) -> Vec { @@ -602,7 +604,7 @@ impl NewSessionWindow { session_id: i64, cx: &Context, ) -> anyhow::Result { - let backend = Self::load_default_backend(); + let backend = self.shell.common.backend; let (shell_program, term, colorterm, charset, label, group) = ( self.shell.program.clone(), self.shell.common.term.clone(), @@ -751,7 +753,7 @@ impl NewSessionWindow { } fn connect_new_local_shell(&mut self, cx: &mut Context) -> anyhow::Result<()> { - let backend = Self::load_default_backend(); + let backend = self.shell.common.backend; let (shell_program, term, colorterm, charset) = ( self.shell.program.clone(), self.shell.common.term.clone(), @@ -936,7 +938,7 @@ impl NewSessionWindow { session_id: i64, cx: &Context, ) -> anyhow::Result { - let backend = Self::load_default_backend(); + let backend = self.serial.common.backend; let ( port, baud_raw, @@ -1001,7 +1003,7 @@ impl NewSessionWindow { } fn connect_new_serial(&mut self, cx: &mut Context) -> anyhow::Result<()> { - let backend = Self::load_default_backend(); + let backend = self.serial.common.backend; let ( port, baud_raw, @@ -1136,6 +1138,7 @@ impl NewSessionWindow { Self::apply_common_state_fields( &mut self.shell.common, + session.backend, &term, &colorterm, &charset, @@ -1146,6 +1149,7 @@ impl NewSessionWindow { ); Self::apply_common_state_fields( &mut self.ssh.common, + session.backend, &term, &colorterm, &charset, @@ -1156,6 +1160,7 @@ impl NewSessionWindow { ); Self::apply_common_state_fields( &mut self.serial.common, + session.backend, &term, &colorterm, &charset, @@ -1168,6 +1173,7 @@ impl NewSessionWindow { fn apply_common_state_fields( common: &mut super::state::SessionCommonState, + backend: crate::settings::TerminalBackend, term: &gpui::SharedString, colorterm: &gpui::SharedString, charset: &gpui::SharedString, @@ -1176,6 +1182,7 @@ impl NewSessionWindow { window: &mut Window, cx: &mut Context, ) { + common.set_backend(backend, window, cx); common.set_term(term.clone(), window, cx); common.set_colorterm(colorterm.as_ref(), window, cx); common.set_charset(charset.clone(), window, cx); @@ -1189,9 +1196,18 @@ impl NewSessionWindow { window: &mut Window, cx: &mut Context, ) { - let _ = session; - self.shell - .set_program(gpui_term::shell::default_shell_program(), window, cx); + let shell_program = session + .env + .as_deref() + .and_then(|env| { + session_store_env_value(env, SESSION_ENV_TERMUA_SHELL) + .or_else(|| session_store_env_value(env, SESSION_ENV_SHELL)) + }) + .map(str::trim) + .filter(|program| !program.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| self.shell.program.to_string()); + self.shell.set_program(&shell_program, window, cx); // The shell program may auto-sync the label; restore the persisted label/group. set_input_value( @@ -1452,7 +1468,7 @@ impl NewSessionWindow { app: &gpui::App, ) -> anyhow::Result<()> { let (backend, shell_program, term, colorterm, charset, label, group) = ( - Self::load_default_backend(), + self.shell.common.backend, self.shell.program.clone(), self.shell.common.term.clone(), self.shell.common.colorterm.to_string(), @@ -1483,7 +1499,7 @@ impl NewSessionWindow { term.as_ref(), Self::trimmed_non_empty_option(colorterm.as_str()), charset.as_ref(), - Self::session_env_rows_for_store(&self.shell.env_rows, app).as_slice(), + self.shell_session_env_for_store(app).as_slice(), ); let (term, colorterm, charset) = session_store_terminal_fields_from_env(&env); diff --git a/termua/src/window/new_session/mod.rs b/termua/src/window/new_session/mod.rs index a5346e9..f70025d 100644 --- a/termua/src/window/new_session/mod.rs +++ b/termua/src/window/new_session/mod.rs @@ -14,6 +14,14 @@ const SHELL_SESSION_ID: &str = "shell.session"; const SSH_SESSION_ID: &str = "ssh.session"; const SERIAL_SESSION_ID: &str = "serial.session"; const DEFAULT_COLORTERM: &str = "truecolor"; +const RESERVED_TERMINAL_ENV_NAMES: &[&str] = + &["TERM", "COLORTERM", "CHARSET", "SHELL", "TERMUA_SHELL"]; + +fn is_reserved_terminal_env_name(name: &str) -> bool { + RESERVED_TERMINAL_ENV_NAMES + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) +} use nav::{ Page, build_nav_tree_items, default_selected_item_id, find_tree_item_by_id, @@ -33,8 +41,9 @@ pub use state::Protocol; use state::{ EnvRowState, ProxyEnvRowState, ProxyJumpRowState, SerialDataBitsSelectItem, SerialFlowControlSelectItem, SerialParitySelectItem, SerialSessionState, - SerialStopBitsSelectItem, SessionCommonState, SessionEditorMode, ShellSessionState, - SshAuthSelectItem, SshAuthType, SshProxySelectItem, SshSessionState, shell_program_title, + SerialStopBitsSelectItem, SessionCommonState, SessionEditorMode, ShellProgramSelectItem, + ShellSessionState, SshAuthSelectItem, SshAuthType, SshProxySelectItem, SshSessionState, + TerminalBackendSelectItem, shell_program_title, }; pub struct NewSessionWindow { @@ -341,12 +350,16 @@ impl NewSessionWindow { let nav_tree_items = build_nav_tree_items(protocol); let nav_tree_state = cx.new(|cx| TreeState::new(cx).items(nav_tree_items.clone())); - let shell = ShellSessionState::new(window, cx); - let mut ssh = SshSessionState::new(window, cx); + let default_backend = crate::settings::load_settings_from_disk() + .unwrap_or_default() + .terminal + .default_backend; + let shell = ShellSessionState::new(default_backend, window, cx); + let mut ssh = SshSessionState::new(default_backend, window, cx); if mode.is_edit() { ssh.password_edit_unlocked = false; } - let serial = SerialSessionState::new(window, cx); + let serial = SerialSessionState::new(default_backend, window, cx); let mut this = Self { focus_handle: cx.focus_handle(), @@ -544,6 +557,23 @@ impl NewSessionWindow { } fn install_shell_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { + Self::subscribe_backend_select( + &mut self._subscriptions, + Protocol::Shell, + &self.shell.common.backend_select, + window, + cx, + ); + self._subscriptions + .push(cx.subscribe_in(&self.shell.program_select, window, { + move |this, _select, ev, window, cx| { + if let SelectEvent::Confirm(Some(program)) = ev { + this.shell.set_program(program.as_ref(), window, cx); + cx.notify(); + window.refresh(); + } + } + })); self._subscriptions .push(cx.subscribe_in(&self.shell.common.term_select, window, { move |this, _select, ev: &SelectEvent>, window, cx| { @@ -580,6 +610,13 @@ impl NewSessionWindow { } fn install_ssh_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { + Self::subscribe_backend_select( + &mut self._subscriptions, + Protocol::Ssh, + &self.ssh.common.backend_select, + window, + cx, + ); self._subscriptions .push(cx.subscribe_in(&self.ssh.host_input, window, { move |_this, _input, ev, window, cx| { @@ -668,6 +705,13 @@ impl NewSessionWindow { } fn install_serial_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { + Self::subscribe_backend_select( + &mut self._subscriptions, + Protocol::Serial, + &self.serial.common.backend_select, + window, + cx, + ); self._subscriptions .push(cx.subscribe_in(&self.serial.common.term_select, window, { move |this, _select, ev: &SelectEvent>, window, cx| { @@ -705,6 +749,34 @@ impl NewSessionWindow { } })); } + + fn subscribe_backend_select( + subscriptions: &mut Vec, + protocol: Protocol, + select: &Entity>>, + window: &mut Window, + cx: &mut Context, + ) { + subscriptions.push(cx.subscribe_in(select, window, { + move |this, + _select, + ev: &SelectEvent>, + window, + cx| { + let SelectEvent::Confirm(Some(backend)) = ev else { + return; + }; + match protocol { + Protocol::Shell => &mut this.shell.common, + Protocol::Ssh => &mut this.ssh.common, + Protocol::Serial => &mut this.serial.common, + } + .set_backend(*backend, window, cx); + cx.notify(); + window.refresh(); + } + })); + } } impl Focusable for NewSessionWindow { @@ -790,7 +862,23 @@ impl SessionCommonState { ); } - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new( + backend: crate::settings::TerminalBackend, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let backend_select = new_select( + window, + cx, + vec![ + TerminalBackendSelectItem::new(crate::settings::TerminalBackend::Alacritty), + TerminalBackendSelectItem::new(crate::settings::TerminalBackend::Wezterm), + ], + Some(match backend { + crate::settings::TerminalBackend::Alacritty => 0, + crate::settings::TerminalBackend::Wezterm => 1, + }), + ); let term_select = new_select( window, cx, @@ -814,6 +902,8 @@ impl SessionCommonState { let colorterm_options = colorterm_options(); let colorterm_select = new_select(window, cx, colorterm_options.clone(), Some(0)); Self { + backend, + backend_select, term: "xterm-256color".into(), colorterm: DEFAULT_COLORTERM.into(), charset: "UTF-8".into(), @@ -826,6 +916,18 @@ impl SessionCommonState { } } + fn set_backend( + &mut self, + backend: crate::settings::TerminalBackend, + window: &mut Window, + cx: &mut Context, + ) { + self.backend = backend; + self.backend_select.update(cx, |select, cx| { + select.set_selected_value(&backend, window, cx); + }); + } + fn set_term( &mut self, term: SharedString, @@ -874,17 +976,28 @@ impl SessionCommonState { } impl ShellSessionState { - fn program_default_value() -> SharedString { - gpui_term::shell::default_shell_program().into() - } - - fn new(window: &mut Window, cx: &mut Context) -> Self { - let common = SessionCommonState::new(window, cx); + fn new( + backend: crate::settings::TerminalBackend, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let common = SessionCommonState::new(backend, window, cx); - let program = Self::program_default_value(); + let program_options = gpui_term::shell::available_shell_programs() + .into_iter() + .map(ShellProgramSelectItem::new) + .collect::>(); + let program = program_options + .first() + .expect("shell detection always returns a fallback") + .value() + .clone(); + let program_select = new_select(window, cx, program_options.clone(), Some(0)); let this = Self { program, + program_options, + program_select, env_rows: Vec::new(), env_next_id: 1, common, @@ -913,6 +1026,22 @@ impl ShellSessionState { let program: SharedString = program.to_string().into(); self.program = program.clone(); + if !self + .program_options + .iter() + .any(|item| item.value() == &program) + { + 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 = { @@ -950,8 +1079,12 @@ impl ShellSessionState { } impl SshSessionState { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let common = SessionCommonState::new(window, cx); + fn new( + backend: crate::settings::TerminalBackend, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let common = SessionCommonState::new(backend, window, cx); let auth_select = new_select( window, @@ -1138,8 +1271,12 @@ impl SshSessionState { } impl SerialSessionState { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let common = SessionCommonState::new(window, cx); + fn new( + backend: crate::settings::TerminalBackend, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let common = SessionCommonState::new(backend, window, cx); let ports = Vec::::new(); let port_select = new_select(window, cx, ports.clone(), None); diff --git a/termua/src/window/new_session/render.rs b/termua/src/window/new_session/render.rs index 4f6a535..1603f0c 100644 --- a/termua/src/window/new_session/render.rs +++ b/termua/src/window/new_session/render.rs @@ -22,18 +22,11 @@ use rust_i18n::t; use super::{ NewSessionWindow, Page, Protocol, SerialSessionState, ShellSessionState, SshAuthType, - SshSessionState, new_proxy_jump_row_state, ssh, ssh::ssh_user_input_box_width, + SshSessionState, is_reserved_terminal_env_name, new_proxy_jump_row_state, ssh, + ssh::ssh_user_input_box_width, }; use crate::store::SshProxyMode; -const RESERVED_TERMINAL_ENV_NAMES: &[&str] = &["TERM", "COLORTERM", "CHARSET"]; - -fn is_reserved_terminal_env_name(name: &str) -> bool { - RESERVED_TERMINAL_ENV_NAMES - .iter() - .any(|reserved| name.eq_ignore_ascii_case(reserved)) -} - fn reserved_terminal_env_hint() -> String { t!("NewSession.Hint.ReservedTerminalEnv").to_string() } @@ -92,10 +85,11 @@ impl Render for NewSessionWindow { .lock_overlay .render_overlay_if_locked(Self::unlock_from_overlay, cx); - let default_backend = crate::settings::load_settings_from_disk() - .unwrap_or_default() - .terminal - .default_backend; + let backend = match self.protocol { + Protocol::Shell => self.shell.common.backend, + Protocol::Ssh => self.ssh.common.backend, + Protocol::Serial => self.serial.common.backend, + }; v_flex() .id("termua-new-session-window") @@ -128,7 +122,7 @@ impl Render for NewSessionWindow { div() .debug_selector(|| "termua-new-session-titlebar-icon".to_string()) .child( - img(backend_icon(default_backend)) + img(backend_icon(backend)) .w(px(16.)) .h(px(16.)) .flex_shrink_0() @@ -540,6 +534,22 @@ fn render_form_row( .child(div().flex_1().min_w(px(280.)).child(control)) } +fn render_backend_type_row( + common: &super::SessionCommonState, + selector: &'static str, + cx: &mut Context, +) -> gpui::AnyElement { + render_form_row( + t!("NewSession.Field.Type").to_string(), + div() + .w_full() + .debug_selector(move || selector.to_string()) + .child(Select::new(&common.backend_select)), + cx, + ) + .into_any_element() +} + impl ShellSessionState { fn render_env_editor( &self, @@ -633,6 +643,24 @@ impl ShellSessionState { v_flex() .id("termua-new-session-shell-session") .gap_3() + .child(render_backend_type_row( + &self.common, + "termua-new-session-shell-backend-type-select", + cx, + )) + .child(render_form_row( + t!("NewSession.Field.Shell").to_string(), + div() + .w_full() + .debug_selector(|| "termua-new-session-shell-type".to_string()) + .child( + div() + .w_full() + .debug_selector(|| "termua-new-session-shell-type-select".to_string()) + .child(Select::new(&self.program_select)), + ), + cx, + )) .child(render_form_row( t!("NewSession.Field.Label").to_string(), div().w_full().child(Input::new(&self.common.label_input)), @@ -1183,6 +1211,11 @@ impl SshSessionState { .then_some(t!("NewSession.Ssh.Error.PortRange").to_string()); let mut rows = Vec::new(); + rows.push(render_backend_type_row( + &self.common, + "termua-new-session-ssh-backend-type-select", + cx, + )); rows.push(self.render_host_row(host_error, port_error, window, cx)); rows.push(self.render_auth_type_row(cx)); if let Some(row) = self.render_password_row(view.clone(), !self.password_edit_unlocked, cx) @@ -1374,6 +1407,11 @@ impl SerialSessionState { v_flex() .id("termua-new-session-serial-session") .gap_3() + .child(render_backend_type_row( + &self.common, + "termua-new-session-serial-backend-type-select", + cx, + )) .child(render_form_row( t!("NewSession.Serial.Field.Port").to_string(), div() diff --git a/termua/src/window/new_session/state.rs b/termua/src/window/new_session/state.rs index bd2ab38..ee6c22d 100644 --- a/termua/src/window/new_session/state.rs +++ b/termua/src/window/new_session/state.rs @@ -1,11 +1,19 @@ -use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Window, div}; +use gpui::{ + AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, StyledImage, Window, + div, img, px, +}; +use gpui_common::TermuaIcon; use gpui_component::{ + Icon, Sizable, h_flex, input::InputState, select::{SearchableVec, SelectItem, SelectState}, }; use rust_i18n::t; -use crate::store::{SerialFlowControl, SerialParity, SerialStopBits, SshProxyMode}; +use crate::{ + settings::TerminalBackend, + store::{SerialFlowControl, SerialParity, SerialStopBits, SshProxyMode}, +}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub(super) enum SessionEditorMode { @@ -27,6 +35,8 @@ impl SessionEditorMode { } pub(super) struct SessionCommonState { + pub(super) backend: TerminalBackend, + pub(super) backend_select: Entity>>, pub(super) term: SharedString, pub(super) colorterm: SharedString, pub(super) charset: SharedString, @@ -38,17 +48,108 @@ pub(super) struct SessionCommonState { pub(super) charset_select: Entity>>, } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(super) struct TerminalBackendSelectItem { + backend: TerminalBackend, +} + +impl TerminalBackendSelectItem { + pub(super) fn new(backend: TerminalBackend) -> Self { + Self { backend } + } + + fn label(&self) -> &'static str { + match self.backend { + TerminalBackend::Alacritty => "Alacritty", + TerminalBackend::Wezterm => "WezTerm", + } + } + + pub(super) fn icon(&self) -> TermuaIcon { + match self.backend { + TerminalBackend::Alacritty => TermuaIcon::Alacritty, + TerminalBackend::Wezterm => TermuaIcon::Wezterm, + } + } + + fn render_title(&self) -> impl IntoElement { + h_flex() + .items_center() + .gap_2() + .child( + img(self.icon()) + .w(px(16.)) + .h(px(16.)) + .flex_shrink_0() + .object_fit(gpui::ObjectFit::Contain), + ) + .child(div().child(self.label())) + } +} + +impl SelectItem for TerminalBackendSelectItem { + type Value = TerminalBackend; + + fn title(&self) -> SharedString { + self.label().into() + } + + fn display_title(&self) -> Option { + Some(self.render_title().into_any_element()) + } + + fn render(&self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + self.render_title() + } + + fn value(&self) -> &Self::Value { + &self.backend + } +} + 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, } pub(super) fn shell_program_title(program: &str) -> SharedString { - match program { - "pwsh" => SharedString::from("powershell"), - other => SharedString::from(other.to_string()), + gpui_term::shell::shell_display_name(program).into() +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ShellProgramSelectItem { + program: SharedString, +} + +impl ShellProgramSelectItem { + pub(super) fn new(program: impl Into) -> Self { + Self { + program: program.into(), + } + } + + pub(super) fn icon(&self) -> TermuaIcon { + use gpui_term::shell::ShellKind; + + match gpui_term::shell::shell_kind(self.program.as_ref()) { + ShellKind::Bash => TermuaIcon::Terminal, + ShellKind::Fish => TermuaIcon::Fish, + ShellKind::Nu => TermuaIcon::Nushell, + ShellKind::Pwsh | ShellKind::PowerShell => TermuaIcon::Pwsh, + ShellKind::Zsh | ShellKind::Cmd | ShellKind::Other => TermuaIcon::Terminal, + } + } + + fn render_title(&self) -> impl IntoElement { + h_flex() + .items_center() + .gap_2() + .child(Icon::empty().path(self.icon().path()).small()) + .child(div().child(self.title())) } } @@ -209,6 +310,26 @@ impl SelectItem for SshAuthSelectItem { } } +impl SelectItem for ShellProgramSelectItem { + type Value = SharedString; + + fn title(&self) -> SharedString { + shell_program_title(self.program.as_ref()) + } + + fn display_title(&self) -> Option { + Some(self.render_title().into_any_element()) + } + + fn render(&self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + self.render_title() + } + + fn value(&self) -> &Self::Value { + &self.program + } +} + impl SelectItem for SshProxySelectItem { type Value = SshProxyMode; diff --git a/termua/src/window/new_session/tests.rs b/termua/src/window/new_session/tests.rs index 622ccd7..3243057 100644 --- a/termua/src/window/new_session/tests.rs +++ b/termua/src/window/new_session/tests.rs @@ -28,11 +28,77 @@ fn test_session_env( Some(env) } +fn test_local_session(env: Vec) -> crate::store::Session { + crate::store::Session { + id: 1, + protocol: crate::store::SessionType::Local, + group_path: "local".to_string(), + label: "saved shell".to_string(), + backend: crate::settings::TerminalBackend::Alacritty, + env: Some(env), + ssh_host: None, + ssh_port: None, + ssh_auth_type: None, + ssh_user: None, + ssh_credential_username: None, + ssh_password: None, + ssh_tcp_nodelay: false, + ssh_tcp_keepalive: false, + ssh_proxy_mode: None, + ssh_proxy_command: None, + ssh_proxy_workdir: None, + ssh_proxy_env: None, + ssh_proxy_jump: None, + serial_port: None, + serial_baud: None, + serial_data_bits: None, + serial_parity: None, + serial_stop_bits: None, + serial_flow_control: None, + } +} + #[test] fn new_session_colorterm_field_label_uses_camel_case_locale() { assert_eq!(t!("NewSession.Field.ColorTerm"), "ColorTerm:"); } +#[test] +fn shell_program_select_item_icons_match_shell_kinds() { + use gpui_common::TermuaIcon; + + for (program, expected) in [ + ("bash", TermuaIcon::Terminal), + ("zsh", TermuaIcon::Terminal), + ("fish", TermuaIcon::Fish), + ("nu", TermuaIcon::Nushell), + ("pwsh", TermuaIcon::Pwsh), + ("powershell", TermuaIcon::Pwsh), + ("cmd", TermuaIcon::Terminal), + ("custom-shell", TermuaIcon::Terminal), + ] { + assert_eq!(ShellProgramSelectItem::new(program).icon(), expected); + } +} + +#[test] +fn terminal_backend_select_item_icons_match_backends() { + use gpui_common::TermuaIcon; + + for (backend, expected) in [ + ( + crate::settings::TerminalBackend::Alacritty, + TermuaIcon::Alacritty, + ), + ( + crate::settings::TerminalBackend::Wezterm, + TermuaIcon::Wezterm, + ), + ] { + assert_eq!(TerminalBackendSelectItem::new(backend).icon(), expected); + } +} + #[gpui::test] fn new_session_colorterm_renders_select_controls(cx: &mut gpui::TestAppContext) { use std::sync::{Arc, Mutex}; @@ -836,7 +902,7 @@ fn new_session_ssh_proxy_page_renders_jumpserver_controls(cx: &mut gpui::TestApp } #[gpui::test] -fn new_session_session_pages_do_not_render_type_controls(cx: &mut gpui::TestAppContext) { +fn new_session_shell_type_control_renders(cx: &mut gpui::TestAppContext) { cx.update(|app| { menubar::init(app); gpui_term::init(app); @@ -859,12 +925,12 @@ fn new_session_session_pages_do_not_render_type_controls(cx: &mut gpui::TestAppC assert!( shell .debug_bounds("termua-new-session-shell-type") - .is_none() + .is_some() ); assert!( shell .debug_bounds("termua-new-session-shell-type-select") - .is_none() + .is_some() ); // Switch to SSH protocol and ensure the SSH session page has its type dropdown. @@ -1001,7 +1067,7 @@ fn new_session_default_type_matches_terminal_default_backend_setting( )); std::fs::create_dir_all(&tmp_dir).unwrap(); - // Write a settings.json that selects Alacritty as the default backend. + // WezTerm differs from the enum default, so this proves the setting was loaded. let path = tmp_dir.join("termua").join("settings.json"); let _guard = crate::settings::override_settings_json_path(path.clone()); if let Some(parent) = path.parent() { @@ -1010,7 +1076,7 @@ fn new_session_default_type_matches_terminal_default_backend_setting( std::fs::write( &path, r#"{ - "terminal": { "default_backend": "alacritty" } + "terminal": { "default_backend": "wezterm" } }"#, ) .unwrap(); @@ -1020,26 +1086,75 @@ fn new_session_default_type_matches_terminal_default_backend_setting( gpui_term::init(app); }); - let shell = cx.add_empty_window(); - shell.draw( + use std::sync::{Arc, Mutex}; + + let win = cx.add_empty_window(); + let view_slot: Arc>>> = Arc::new(Mutex::new(None)); + let view_slot_for_draw = Arc::clone(&view_slot); + win.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| { + move |window, app| { let view = app.new(|cx| NewSessionWindow::new(window, cx)); + *view_slot_for_draw.lock().unwrap() = Some(view.clone()); div().size_full().child(view) }, ); - shell.run_until_parked(); + win.run_until_parked(); - shell.update(|_window, app| { - let view = app.new(|cx| NewSessionWindow::new(_window, cx)); - let _view = view.read(app); + let view = view_slot.lock().unwrap().clone().unwrap(); + win.update(|_window, app| { + let view = view.read(app); + for common in [&view.shell.common, &view.ssh.common, &view.serial.common] { + assert_eq!(common.backend, crate::settings::TerminalBackend::Wezterm); + assert_eq!( + common.backend_select.read(app).selected_value(), + Some(&crate::settings::TerminalBackend::Wezterm) + ); + } }); } +#[gpui::test] +fn new_session_renders_terminal_backend_type_for_every_protocol(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + menubar::init(app); + gpui_term::init(app); + }); + + for (protocol, selector) in [ + ( + Protocol::Shell, + "termua-new-session-shell-backend-type-select", + ), + (Protocol::Ssh, "termua-new-session-ssh-backend-type-select"), + ( + Protocol::Serial, + "termua-new-session-serial-backend-type-select", + ), + ] { + let win = cx.add_empty_window(); + win.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(800.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |window, app| { + let view = app.new(|cx| { + NewSessionWindow::new_with_mode(SessionEditorMode::New, protocol, window, cx) + }); + div().size_full().child(view) + }, + ); + win.run_until_parked(); + assert!(win.debug_bounds(selector).is_some(), "missing {selector}"); + } +} + #[gpui::test] fn new_session_group_controls_render_inputs(cx: &mut gpui::TestAppContext) { cx.update(|app| { @@ -1189,16 +1304,65 @@ fn new_session_shell_label_follows_shell_program(cx: &mut gpui::TestAppContext) win.run_until_parked(); win.update(|_window, app| { + let shell = &view.read(app).shell; assert_eq!( - view.read(app) - .shell - .common - .label_input - .read(app) - .value() - .as_ref(), + shell.common.label_input.read(app).value().as_ref(), "powershell" ); + assert_eq!( + shell.program_select.read(app).selected_value(), + Some(&SharedString::from("pwsh")) + ); + }); +} + +#[gpui::test] +fn edit_local_session_restores_shell_type(cx: &mut gpui::TestAppContext) { + use std::sync::{Arc, Mutex}; + + cx.update(|app| { + menubar::init(app); + gpui_term::init(app); + }); + + let win = cx.add_empty_window(); + let view_slot: Arc>>> = Arc::new(Mutex::new(None)); + let view_slot_for_draw = Arc::clone(&view_slot); + win.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(800.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |window, app| { + let mut session = test_local_session(vec![SessionEnvVar { + name: gpui_term::shell::TERMUA_SHELL_ENV_KEY.to_string(), + value: "nu".to_string(), + }]); + session.backend = crate::settings::TerminalBackend::Wezterm; + let view = app.new(|cx| NewSessionWindow::new_for_edit(session, window, cx)); + *view_slot_for_draw.lock().unwrap() = Some(view.clone()); + div().size_full().child(view) + }, + ); + win.run_until_parked(); + + let view = view_slot.lock().unwrap().clone().unwrap(); + win.update(|_window, app| { + let shell = &view.read(app).shell; + assert_eq!(shell.program.as_ref(), "nu"); + assert_eq!( + shell.program_select.read(app).selected_value(), + Some(&SharedString::from("nu")) + ); + assert_eq!( + shell.common.backend, + crate::settings::TerminalBackend::Wezterm + ); + assert_eq!( + shell.common.backend_select.read(app).selected_value(), + Some(&crate::settings::TerminalBackend::Wezterm) + ); }); } @@ -1316,7 +1480,6 @@ fn edit_session_disables_protocol_switching(cx: &mut gpui::TestAppContext) { .unwrap() .clone() .expect("expected view to be captured"); - win.update(|window, app| { view.update(app, |this, cx| { this.set_protocol(Protocol::Shell, cx); @@ -1623,6 +1786,19 @@ fn new_local_connect_persists_session_in_store(cx: &mut gpui::TestAppContext) { .unwrap() .clone() .expect("expected view to be captured"); + let selected_backend = match crate::settings::load_settings_from_disk() + .unwrap_or_default() + .terminal + .default_backend + { + crate::settings::TerminalBackend::Alacritty => crate::settings::TerminalBackend::Wezterm, + crate::settings::TerminalBackend::Wezterm => crate::settings::TerminalBackend::Alacritty, + }; + win.update(|window, app| { + view.update(app, |this, cx| { + this.shell.common.set_backend(selected_backend, window, cx); + }); + }); let expected_label = win.update(|_window, app| { let view = view.read(app); view.shell.common.label_input.read(app).value().to_string() @@ -1643,6 +1819,7 @@ 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].backend, selected_backend); } #[gpui::test] @@ -1681,6 +1858,7 @@ fn new_local_connect_persists_colorterm_and_env_in_store(cx: &mut gpui::TestAppC .unwrap() .clone() .expect("expected view to be captured"); + let expected_shell = win.update(|_window, app| view.read(app).shell.program.to_string()); win.update(|window, app| { view.update(app, |this, cx| { @@ -1729,11 +1907,12 @@ fn new_local_connect_persists_colorterm_and_env_in_store(cx: &mut gpui::TestAppC .find(|var| var.name == name) .map(|var| var.value.as_str()) }; - assert_eq!(env.len(), 4); + assert_eq!(env.len(), 5); assert_eq!(env_value("TERM"), Some("xterm-256color")); assert_eq!(env_value("COLORTERM"), Some("truecolor")); assert_eq!(env_value("CHARSET"), Some("UTF-8")); assert_eq!(env_value("FOO"), Some("bar")); + assert_eq!(env_value("TERMUA_SHELL"), Some(expected_shell.as_str())); } #[cfg_attr(target_os = "macos", ignore)] From 90c96ea4dd0f80603403097e0f61ff3ee394012a Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 10:58:31 +0800 Subject: [PATCH 2/8] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3b4a973..ce6d8ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ .DS_Store .idea target -.worktree/ From 56dbd3d7b497858177fcad271454ac3b46a66e4b Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 11:17:56 +0800 Subject: [PATCH 3/8] fix(gpui-term): stabilize line number gutter after resize --- crates/gpui_term/src/element.rs | 26 ++++++++++++++++-------- crates/gpui_term/src/view/line_number.rs | 23 ++++++++++++++++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs index 809ac40..3e90f18 100644 --- a/crates/gpui_term/src/element.rs +++ b/crates/gpui_term/src/element.rs @@ -30,7 +30,7 @@ use crate::{ line_number::{ LineNumberPaintData, LineNumberState, compute_line_number_layout, compute_line_number_paint_data, paint_line_numbers, - reserve_left_padding_without_line_numbers, should_relayout_for_mode_change, + reserve_left_padding_without_line_numbers, should_relayout_for_line_number_change, should_show_line_numbers, }, scrolling::{SCROLLBAR_WIDTH, scrollbar_geometry_for_terminal}, @@ -1217,12 +1217,17 @@ impl TerminalElement { let mut last_hovered_word = Self::sync_terminal_for_prepaint(terminal, dimensions, bounds, &hover_word, window, cx); - // After syncing, reconcile line number visibility with the updated mode. - let mode_after_sync = terminal.read(cx).last_content().mode; - if should_relayout_for_mode_change( + // After syncing, reconcile every input that can change the line-number gutter. + let (mode_after_sync, total_lines_after_sync) = { + let terminal = terminal.read(cx); + (terminal.last_content().mode, terminal.total_lines()) + }; + if should_relayout_for_line_number_change( typography.show_line_numbers_setting, initial_mode, + total_lines_for_digits, mode_after_sync, + total_lines_after_sync, ) { show_line_numbers_for_layout = should_show_line_numbers(typography.show_line_numbers_setting, mode_after_sync); @@ -1232,7 +1237,6 @@ impl TerminalElement { mode_after_sync, ); - let total_lines_for_digits = terminal.read(cx).total_lines(); ( dimensions, gutter, @@ -1246,7 +1250,7 @@ impl TerminalElement { typography.show_scrollbar, show_line_numbers_for_layout, reserve_left_padding_without_line_numbers_for_layout, - total_lines_for_digits, + total_lines_after_sync, ); last_hovered_word = Self::sync_terminal_for_prepaint( @@ -3613,7 +3617,9 @@ mod tests { 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}; + use crate::{ + GridPoint, TerminalMode, view::line_number::should_relayout_for_line_number_change, + }; #[test] fn placeholder_highlight_colors_are_theme_derived() { @@ -3640,10 +3646,12 @@ mod tests { #[test] fn relayouts_when_exiting_alt_screen_with_hidden_line_numbers() { - assert!(should_relayout_for_mode_change( + assert!(should_relayout_for_line_number_change( false, TerminalMode::ALT_SCREEN, - TerminalMode::empty() + 24, + TerminalMode::empty(), + 24, )); } diff --git a/crates/gpui_term/src/view/line_number.rs b/crates/gpui_term/src/view/line_number.rs index 5233527..329db77 100644 --- a/crates/gpui_term/src/view/line_number.rs +++ b/crates/gpui_term/src/view/line_number.rs @@ -26,18 +26,24 @@ pub(crate) fn reserve_left_padding_without_line_numbers( !show_line_numbers_setting && !mode.contains(TerminalMode::ALT_SCREEN) } -pub(crate) fn should_relayout_for_mode_change( +pub(crate) fn should_relayout_for_line_number_change( show_line_numbers_setting: bool, previous_mode: TerminalMode, + previous_total_lines: usize, mode_after_sync: TerminalMode, + total_lines_after_sync: usize, ) -> bool { // We compute layout once using the previous snapshot mode as a hint, then sync the backend - // (which updates mode). If either the line number gutter or the left padding policy changes - // across the sync boundary, do a second layout+sync so the user doesn't see a one-frame shift. + // (which can update mode and reflow the buffer). If the line number gutter or left padding + // policy changes across the sync boundary, do a second layout+sync so the user doesn't see a + // one-frame shift. should_show_line_numbers(show_line_numbers_setting, previous_mode) != should_show_line_numbers(show_line_numbers_setting, mode_after_sync) || reserve_left_padding_without_line_numbers(show_line_numbers_setting, previous_mode) != reserve_left_padding_without_line_numbers(show_line_numbers_setting, mode_after_sync) + || (should_show_line_numbers(show_line_numbers_setting, mode_after_sync) + && digit_count(previous_total_lines.max(1)) + != digit_count(total_lines_after_sync.max(1))) } #[derive(Copy, Clone)] @@ -284,6 +290,17 @@ mod tests { assert_eq!(buf, " 42 "); } + #[test] + fn relayouts_when_sync_changes_line_number_digits() { + assert!(super::should_relayout_for_line_number_change( + true, + crate::TerminalMode::empty(), + 6, + crate::TerminalMode::empty(), + 24, + )); + } + #[test] fn paint_data_includes_visible_rows_below_cursor() { let cells = vec![IndexedCell { From e9ed18b390ca930314cf263b5c625aabecf29dfe Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 11:22:22 +0800 Subject: [PATCH 4/8] Update cast_player.rs --- termua/src/cast_player.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/termua/src/cast_player.rs b/termua/src/cast_player.rs index 54bd895..bc196c5 100644 --- a/termua/src/cast_player.rs +++ b/termua/src/cast_player.rs @@ -366,6 +366,7 @@ mod tests { } #[test] + #[rustfmt::skip] fn play_cast_removes_only_complete_fish_newline_probes() { let genuine_output = serde_json::to_string(&(0.0, "o", "user output: ⏎\r\n")).unwrap(); let fish_probe = serde_json::to_string(&( From e46b81c1b195c157898eeb8bb4d188171d454bbc Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 14:32:56 +0800 Subject: [PATCH 5/8] fix: make test happy --- crates/gpui_term/src/shell.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/gpui_term/src/shell.rs b/crates/gpui_term/src/shell.rs index 9da67d9..783bedb 100644 --- a/crates/gpui_term/src/shell.rs +++ b/crates/gpui_term/src/shell.rs @@ -322,7 +322,10 @@ mod tests { #[test] fn detected_shell_programs_filters_unavailable_candidates() { - let detected = detect_shell_programs(None, |program| matches!(program, "zsh" | "nu")); + let detected = + detect_shell_programs_from_candidates(None, &["zsh", "bash", "nu"], |program| { + matches!(program, "zsh" | "nu") + }); assert_eq!(detected, vec!["zsh".to_string(), "nu".to_string()]); } From 5e1625201545f2563297c97ffa03b161fe5c8929 Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 16:01:27 +0800 Subject: [PATCH 6/8] fix(dock): realign tabs after resize --- crates/gpui_dock/src/tab_panel.rs | 134 +++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/crates/gpui_dock/src/tab_panel.rs b/crates/gpui_dock/src/tab_panel.rs index a1e3c13..60d3ebe 100644 --- a/crates/gpui_dock/src/tab_panel.rs +++ b/crates/gpui_dock/src/tab_panel.rs @@ -197,7 +197,7 @@ impl TabPanel { } } - fn scroll_active_tab_into_view(&mut self, cx: &App) { + fn scroll_active_tab_after_resize(&mut self, cx: &App) { let Some(active_panel) = self.active_panel(cx) else { return; }; @@ -207,7 +207,35 @@ impl TabPanel { return; }; - self.tab_bar_scroll_handle.scroll_to_item(active_pos); + let viewport = self.tab_bar_scroll_handle.bounds(); + let Some(active_bounds) = self.tab_bar_scroll_handle.bounds_for_item(active_pos) else { + self.tab_bar_scroll_handle.scroll_to_item(active_pos); + return; + }; + let Some(last_bounds) = self + .tab_bar_scroll_handle + .bounds_for_item(visible_panels.len() - 1) + else { + self.tab_bar_scroll_handle.scroll_to_item(active_pos); + return; + }; + + let mut offset = self.tab_bar_scroll_handle.offset(); + let max_offset_x = self.tab_bar_scroll_handle.max_offset().x; + let content_end_offset_x = (viewport.right() - last_bounds.right()).min(gpui::px(0.)); + offset.x = offset + .x + .clamp(content_end_offset_x.max(-max_offset_x), gpui::px(0.)); + + if active_bounds.size.width > viewport.size.width + || active_bounds.left() + offset.x < viewport.left() + { + offset.x = viewport.left() - active_bounds.left(); + } else if active_bounds.right() + offset.x > viewport.right() { + offset.x = viewport.right() - active_bounds.right(); + } + offset.x = offset.x.clamp(-max_offset_x, gpui::px(0.)); + self.tab_bar_scroll_handle.set_offset(offset); } /// Mark the TabPanel as being used in Tiles. @@ -792,7 +820,7 @@ impl TabPanel { if this.scroll_active_tab_next_frame { this.scroll_active_tab_next_frame = false; - this.scroll_active_tab_into_view(cx); + this.scroll_active_tab_after_resize(cx); cx.notify(); } }); @@ -2060,5 +2088,105 @@ mod tests { offset_x, ); } + + // Resize wider while the tabs still overflow. The last active tab should stay aligned + // with the viewport's right edge instead of leaving stale-scroll empty space after it. + window_cx.simulate_resize(gpui::size(px(1200.), px(360.))); + for _ in 0..2 { + let tab_panel_for_draw = tab_panel.clone(); + window_cx.draw( + point(px(0.), px(0.)), + size( + AvailableSpace::Definite(px(1200.)), + AvailableSpace::Definite(px(360.)), + ), + move |_, _| div().size_full().child(tab_panel_for_draw), + ); + window_cx.run_until_parked(); + } + + let (bounds, last_bounds, offset_x, max_offset_x) = window_cx.update(|_, cx| { + let handle = &tab_panel.read(cx).tab_bar_scroll_handle; + ( + handle.bounds(), + handle + .bounds_for_item(23) + .expect("expected last tab bounds to exist"), + handle.offset().x, + handle.max_offset().x, + ) + }); + assert!( + max_offset_x > px(8.), + "expected tabs to still overflow after widening" + ); + assert!( + (last_bounds.right() + offset_x - bounds.right()).abs() <= px(1.), + "expected the last active tab to remain right-aligned after widening (viewport={:?}, \ + tab={:?}, offset_x={:?})", + bounds, + last_bounds, + offset_x, + ); + + // Repeat with a non-last active tab and a scroll position at the content end. Resizing + // must still avoid exposing the trailing drag/drop spacer. + window_cx.simulate_resize(gpui::size(px(520.), px(360.))); + for _ in 0..2 { + let tab_panel_for_draw = tab_panel.clone(); + window_cx.draw( + point(px(0.), px(0.)), + size( + AvailableSpace::Definite(px(520.)), + AvailableSpace::Definite(px(360.)), + ), + move |_, _| div().size_full().child(tab_panel_for_draw), + ); + window_cx.run_until_parked(); + } + window_cx.update(|window, cx| { + tab_panel.update(cx, |this, cx| this.set_active_ix(22, window, cx)); + let handle = &tab_panel.read(cx).tab_bar_scroll_handle; + let mut offset = handle.offset(); + offset.x = -handle.max_offset().x; + handle.set_offset(offset); + }); + + window_cx.simulate_resize(gpui::size(px(1200.), px(360.))); + for _ in 0..2 { + let tab_panel_for_draw = tab_panel.clone(); + window_cx.draw( + point(px(0.), px(0.)), + size( + AvailableSpace::Definite(px(1200.)), + AvailableSpace::Definite(px(360.)), + ), + move |_, _| div().size_full().child(tab_panel_for_draw), + ); + window_cx.run_until_parked(); + } + + let (bounds, active_bounds, last_bounds, offset_x) = window_cx.update(|_, cx| { + let handle = &tab_panel.read(cx).tab_bar_scroll_handle; + ( + handle.bounds(), + handle + .bounds_for_item(22) + .expect("expected active tab bounds to exist"), + handle + .bounds_for_item(23) + .expect("expected last tab bounds to exist"), + handle.offset().x, + ) + }); + assert!( + active_bounds.left() + offset_x >= bounds.left() - px(1.) + && active_bounds.right() + offset_x <= bounds.right() + px(1.), + "expected the non-last active tab to remain visible after widening" + ); + assert!( + last_bounds.right() + offset_x >= bounds.right() - px(1.), + "expected widening with a non-last active tab not to expose trailing empty space" + ); } } From 50d94942c25910b2943397e9a23139160d247457 Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 3 Aug 2026 21:12:10 +0800 Subject: [PATCH 7/8] feat(gpui-term): add row-aligned terminal markers --- crates/gpui_term/src/element.rs | 139 +++++++- crates/gpui_term/src/view/scrollbar.rs | 22 +- crates/gpui_term/src/view/scrolling.rs | 445 ++++++++++++++----------- crates/gpui_term/src/view/search.rs | 14 +- 4 files changed, 396 insertions(+), 224 deletions(-) diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs index 3e90f18..73f110a 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_line_number_change, should_show_line_numbers, }, - scrolling::{SCROLLBAR_WIDTH, scrollbar_geometry_for_terminal}, + scrolling::{SCROLLBAR_WIDTH, scrollbar_bounds_for_terminal}, }, }; @@ -94,8 +94,9 @@ fn compute_terminal_layout_metrics( }; let mut size = bounds.size; - // The scrollbar is an overlay; do not reserve horizontal space for it. - size.width -= gutter; + // Keep the scrollbar/marker lane outside the terminal cell grid so progress, search-match, + // and soft-wrap markers never cover actual terminal characters. + size.width -= gutter + scrollbar_width; // Workaround: if the terminal is effectively one column wide, some wide // characters can trigger incorrect wrap/damage behavior in the backend. @@ -120,6 +121,7 @@ pub struct LayoutState { hitbox: Hitbox, bg_quads: Vec, text_spans: Vec, + soft_wrap_markers: Vec, relative_highlighted_ranges: Vec<(RangeInclusive, Hsla)>, cursor: Option, background_color: Hsla, @@ -707,7 +709,6 @@ impl TerminalElement { terminal_view: &Entity, suggestions_hovered_row: Option, sb_bounds: Bounds, - track: Bounds, e: &MouseMoveEvent, cx: &mut App, ) { @@ -724,7 +725,7 @@ impl TerminalElement { return; } - view.update_scrollbar_preview_at(track, e.position, view_cx); + view.update_scrollbar_preview_at(sb_bounds, e.position, view_cx); }); } @@ -879,15 +880,12 @@ impl TerminalElement { let suggestions_hovered_row = suggestions_overlay_row_at_position(&terminal, &terminal_view, e.position, cx); - let geometry = terminal_view.read(cx).scrollbar_geometry(cx); - let sb_bounds = geometry.bounds; - let track = geometry.track; + let sb_bounds = terminal_view.read(cx).scrollbar_bounds(cx); Self::update_scrollbar_hover_state( &terminal_view, suggestions_hovered_row, sb_bounds, - track, e, cx, ); @@ -1106,6 +1104,7 @@ struct PrepaintArtifacts { relative_highlighted_ranges: Vec<(RangeInclusive, Hsla)>, bg_quads: Vec, text_spans: Vec, + soft_wrap_markers: Vec, scrollbar_bounds: Bounds, } @@ -1335,6 +1334,7 @@ impl TerminalElement { hitbox, bg_quads: artifacts.bg_quads, text_spans: artifacts.text_spans, + soft_wrap_markers: artifacts.soft_wrap_markers, cursor, background_color: artifacts.background_color, dimensions, @@ -1391,8 +1391,7 @@ impl TerminalElement { let terminal_view_read = terminal_view.read(cx); let scroll_top = terminal_view_read.scroll_top(); - let scrollbar_bounds = - scrollbar_geometry_for_terminal(dimensions.bounds, scrollbar_width).bounds; + let scrollbar_bounds = scrollbar_bounds_for_terminal(dimensions.bounds, scrollbar_width); let relative_highlighted_ranges = Self::build_relative_highlighted_ranges( &search_matches, @@ -1418,6 +1417,8 @@ impl TerminalElement { ); let background_color = Self::background_color_for_cells(cells, cx); + let start_line_offset = (display_offset.min(i32::MAX as usize)) as i32; + let soft_wrap_markers = soft_wrap_marker_points(cells, start_line_offset, &search_matches); PrepaintArtifacts { mode, @@ -1430,6 +1431,7 @@ impl TerminalElement { relative_highlighted_ranges, bg_quads, text_spans, + soft_wrap_markers, scrollbar_bounds, } } @@ -1964,6 +1966,44 @@ fn terminal_element_paint_text_spans( cx, ); } + + if !layout.soft_wrap_markers.is_empty() && layout.scrollbar_bounds.size.width > Pixels::ZERO { + let marker = "↩"; + let font_size = layout + .base_text_style + .font_size + .to_pixels(window.rem_size()) + * 0.7; + let shaped = window.text_system().shape_line( + marker.into(), + font_size, + &[TextRun { + len: marker.len(), + font: layout.base_text_style.font(), + color: cx.theme().muted_foreground.opacity(0.55), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + for marker_point in &layout.soft_wrap_markers { + let pos = point( + layout.scrollbar_bounds.left() + + ((layout.scrollbar_bounds.size.width - shaped.width()) / 2.).max(px(0.)), + origin.y + marker_point.line as f32 * layout.dimensions.line_height, + ); + let _ = shaped.paint( + pos, + layout.dimensions.line_height, + TextAlign::Left, + None, + window, + cx, + ); + } + } text_paint_start.elapsed() } @@ -2629,6 +2669,29 @@ impl TextSpanBuilder { } } +fn soft_wrap_marker_points( + grid: &[IndexedCell], + start_line_offset: i32, + search_matches: &[RangeInclusive], +) -> Vec { + grid.iter() + .filter(|cell| cell.flags.contains(CellFlags::WRAPLINE)) + .filter(|cell| { + let line = cell.point.line; + let first_possible = search_matches.partition_point(|range| range.end().line < line); + !search_matches + .get(first_possible) + .is_some_and(|range| range.start().line <= line) + }) + .map(|cell| { + GridPoint::new( + start_line_offset.saturating_add(cell.point.line), + cell.point.column, + ) + }) + .collect() +} + pub(crate) fn build_plan( grid: &[IndexedCell], start_line_offset: i32, @@ -3615,10 +3678,11 @@ mod tests { use super::{ compute_terminal_layout_metrics, highlight_quads_for_range, placeholder_highlight_bgs, - snippet_placeholder_bg_quads, + snippet_placeholder_bg_quads, soft_wrap_marker_points, }; use crate::{ - GridPoint, TerminalMode, view::line_number::should_relayout_for_line_number_change, + CellFlags, GridPoint, IndexedCell, TerminalMode, + view::line_number::should_relayout_for_line_number_change, }; #[test] @@ -3638,10 +3702,16 @@ mod tests { // If line numbers are hidden, we still want some breathing room from the left edge. // (Minimum of 14px, otherwise one-third of a cell width.) - let (_dimensions, gutter, _line_number_width, _line_number_digits, _scrollbar_width) = + let (dimensions, gutter, _line_number_width, _line_number_digits, _scrollbar_width) = compute_terminal_layout_metrics(bounds, px(6.0), px(12.0), false, false, true, 100); assert_eq!(gutter, px(14.0)); + assert_eq!(dimensions.bounds.size.width, px(286.0)); + + let (dimensions, _, _, _, scrollbar_width) = + compute_terminal_layout_metrics(bounds, px(6.0), px(12.0), true, false, true, 100); + assert_eq!(scrollbar_width, px(14.0)); + assert_eq!(dimensions.bounds.size.width, px(272.0)); } #[test] @@ -3655,6 +3725,47 @@ mod tests { )); } + #[test] + fn soft_wrap_markers_only_include_wrapped_rows() { + let cells = vec![ + IndexedCell { + point: GridPoint::new(-2, 3), + cell: crate::Cell { + c: 'a', + flags: CellFlags::WRAPLINE, + ..Default::default() + }, + }, + IndexedCell { + point: GridPoint::new(-1, 3), + cell: crate::Cell { + c: 'b', + ..Default::default() + }, + }, + ]; + + assert_eq!( + soft_wrap_marker_points(&cells, 2, &[]), + vec![GridPoint::new(0, 3)] + ); + } + + #[test] + fn search_match_suppresses_soft_wrap_marker_on_same_row() { + let cells = vec![IndexedCell { + point: GridPoint::new(-2, 3), + cell: crate::Cell { + c: 'a', + flags: CellFlags::WRAPLINE, + ..Default::default() + }, + }]; + let matches = vec![GridPoint::new(-2, 0)..=GridPoint::new(-2, 1)]; + + assert!(soft_wrap_marker_points(&cells, 2, &matches).is_empty()); + } + #[test] fn highlight_quads_for_range_single_line() { let range = RangeInclusive::new(GridPoint::new(2, 4), GridPoint::new(2, 7)); diff --git a/crates/gpui_term/src/view/scrollbar.rs b/crates/gpui_term/src/view/scrollbar.rs index e5e0215..e21eab8 100644 --- a/crates/gpui_term/src/view/scrollbar.rs +++ b/crates/gpui_term/src/view/scrollbar.rs @@ -16,7 +16,7 @@ use crate::{ settings::TerminalSettings, view::scrolling::{ SCROLLBAR_ACTIVE_MARKER_SIZE, SCROLLBAR_MARKER_LIMIT, SCROLLBAR_MARKER_SIZE, - SCROLLBAR_WIDTH, ScrollbarMarkerSpec, ScrollbarPreview, + SCROLLBAR_WIDTH, ScrollbarMarkerSpec, ScrollbarMarkerViewport, ScrollbarPreview, scroll_offset_for_line_coord_centered, scrollbar_marker_specs, }, }; @@ -237,12 +237,22 @@ impl TerminalView { return None; } - let geometry = self.scrollbar_geometry(cx); + let lane_bounds = self.scrollbar_bounds(cx); + let content = terminal.last_content(); + let content_y_offset = if content.display_offset == 0 { + self.scroll_top() + } else { + Pixels::ZERO + }; let active_match_index = terminal.active_match_index(); let marker_specs = scrollbar_marker_specs( - geometry.track, - total_lines, - viewport_lines, + ScrollbarMarkerViewport::new( + lane_bounds, + content.terminal_bounds.line_height, + content_y_offset, + content.display_offset, + viewport_lines, + ), matches, active_match_index, SCROLLBAR_MARKER_LIMIT, @@ -254,8 +264,6 @@ impl TerminalView { let marker_color = cx.theme().foreground.opacity(0.30); let active_marker_color = cx.theme().foreground.opacity(0.70); let marker_specs_for_click = marker_specs.clone(); - let lane_bounds = geometry.bounds; - let overlay = div() .id("terminal-scrollbar-markers") .debug_selector(|| "terminal-scrollbar-markers".to_string()) diff --git a/crates/gpui_term/src/view/scrolling.rs b/crates/gpui_term/src/view/scrolling.rs index 5365f45..68eae9e 100644 --- a/crates/gpui_term/src/view/scrolling.rs +++ b/crates/gpui_term/src/view/scrolling.rs @@ -1,4 +1,4 @@ -use std::{cell::Cell, cmp, collections::HashMap, ops::RangeInclusive, rc::Rc, time::Duration}; +use std::{cell::Cell, cmp, ops::RangeInclusive, rc::Rc, time::Duration}; use gpui::{ App, Bounds, Context, Pixels, Point, ReadGlobal, ScrollWheelEvent, Window, point, px, size, @@ -17,7 +17,6 @@ use crate::{ }; pub(crate) const SCROLLBAR_WIDTH: Pixels = px(14.0); -pub(crate) const SCROLLBAR_PAD: Pixels = px(2.0); pub(crate) const SCROLLBAR_MARKER_HIT_RADIUS: Pixels = px(7.0); pub(crate) const SCROLLBAR_MARKER_LIMIT: usize = 4096; pub(crate) const SCROLLBAR_MARKER_SIZE: Pixels = px(4.0); @@ -173,113 +172,114 @@ pub(crate) struct ScrollbarPreview { pub(crate) match_range: RangeInclusive, } -#[derive(Clone, Copy)] -pub(crate) struct ScrollbarGeometry { - pub(crate) bounds: Bounds, - pub(crate) track: Bounds, -} - -pub(crate) fn scrollbar_track_bounds(sb: Bounds) -> Bounds { - let pad = SCROLLBAR_PAD - .min(sb.size.width / 2.0) - .min(sb.size.height / 2.0); - Bounds { - origin: sb.origin + point(pad, pad), - size: size( - (sb.size.width - pad * 2.0).max(Pixels::ZERO), - (sb.size.height - pad * 2.0).max(Pixels::ZERO), - ), - } -} - pub(crate) fn scrollbar_bounds_for_terminal( terminal_bounds: Bounds, scrollbar_width: Pixels, ) -> Bounds { - // Overlay scrollbar: place it inside the terminal bounds so the terminal content can - // render underneath it (no dedicated layout gutter). - let w = scrollbar_width.min(terminal_bounds.size.width.max(Pixels::ZERO)); + // The terminal bounds describe the character grid. The scrollbar and marker lane starts + // immediately after it so terminal characters never render underneath the lane. + let w = scrollbar_width.max(Pixels::ZERO); Bounds { - origin: point( - terminal_bounds.origin.x + (terminal_bounds.size.width - w).max(Pixels::ZERO), - terminal_bounds.origin.y, - ), + origin: point(terminal_bounds.right(), terminal_bounds.origin.y), size: size(w, terminal_bounds.size.height), } } -pub(crate) fn scrollbar_geometry_for_terminal( - terminal_bounds: Bounds, - scrollbar_width: Pixels, -) -> ScrollbarGeometry { - let bounds = scrollbar_bounds_for_terminal(terminal_bounds, scrollbar_width); - ScrollbarGeometry { - bounds, - track: scrollbar_track_bounds(bounds), - } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScrollbarMarkerSpec { + pub(crate) match_index: usize, + pub(crate) y: Pixels, + pub(crate) active: bool, } -pub(crate) fn scrollbar_marker_y_for_line_coord( - track_bounds: Bounds, - total_lines: usize, +#[derive(Clone, Copy)] +pub(crate) struct ScrollbarMarkerViewport { + lane_bounds: Bounds, + line_height: Pixels, + content_y_offset: Pixels, + display_offset: usize, viewport_lines: usize, - line_coord: i32, -) -> Option { - if total_lines == 0 || track_bounds.size.height <= Pixels::ZERO { - return None; - } +} - let top_line = viewport_lines as i32 - total_lines as i32; - let mut idx = line_coord.saturating_sub(top_line); - let max_idx = total_lines.saturating_sub(1) as i32; - if idx < 0 { - idx = 0; - } else if idx > max_idx { - idx = max_idx; +impl ScrollbarMarkerViewport { + pub(crate) fn new( + lane_bounds: Bounds, + line_height: Pixels, + content_y_offset: Pixels, + display_offset: usize, + viewport_lines: usize, + ) -> Self { + Self { + lane_bounds, + line_height, + content_y_offset, + display_offset, + viewport_lines, + } } +} - // Map to the center of the corresponding "line band" in the track, similar to a minimap. - // This avoids piling markers right on the top/bottom edges where they get clamped. - let denom = total_lines.max(1) as f32; - let t = (idx as f32 + 0.5) / denom; - Some(track_bounds.origin.y + track_bounds.size.height * t.clamp(0.0, 1.0)) +fn visible_match_index_range( + matches: &[RangeInclusive], + display_offset: usize, + viewport_lines: usize, +) -> std::ops::Range { + let top_line = -(display_offset as i128); + let bottom_line = top_line + viewport_lines as i128; + let start = + matches.partition_point(|search_match| i128::from(search_match.start().line) < top_line); + let end = + matches.partition_point(|search_match| i128::from(search_match.start().line) < bottom_line); + start..end } -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct ScrollbarMarkerSpec { - pub(crate) match_index: usize, - pub(crate) y: Pixels, - pub(crate) active: bool, +fn match_viewport_row(search_match: &RangeInclusive, display_offset: usize) -> usize { + (i128::from(search_match.start().line) + display_offset as i128) as usize +} + +fn marker_y_for_viewport_row( + lane_bounds: Bounds, + line_height: Pixels, + content_y_offset: Pixels, + viewport_row: usize, +) -> Option { + let y = lane_bounds.origin.y + (viewport_row as f32 + 0.5) * line_height - content_y_offset; + (y >= lane_bounds.top() && y < lane_bounds.bottom()).then_some(y) } pub(crate) fn scrollbar_marker_specs( - track_bounds: Bounds, - total_lines: usize, - viewport_lines: usize, + viewport: ScrollbarMarkerViewport, matches: &[RangeInclusive], active_match_index: Option, limit: usize, ) -> Vec { - if matches.is_empty() || total_lines == 0 || viewport_lines == 0 || limit == 0 { + if matches.is_empty() + || viewport.line_height <= Pixels::ZERO + || viewport.viewport_lines == 0 + || limit == 0 + { return Vec::new(); } - let mut spec_indexes_by_y: HashMap = HashMap::new(); let mut specs: Vec = Vec::new(); - for (match_index, search_match) in matches.iter().enumerate() { - let Some(y) = scrollbar_marker_y_for_line_coord( - track_bounds, - total_lines, - viewport_lines, - search_match.start().line, + let mut last_row = None; + for match_index in + visible_match_index_range(matches, viewport.display_offset, viewport.viewport_lines) + { + let search_match = &matches[match_index]; + let viewport_row = match_viewport_row(search_match, viewport.display_offset); + let Some(y) = marker_y_for_viewport_row( + viewport.lane_bounds, + viewport.line_height, + viewport.content_y_offset, + viewport_row, ) else { continue; }; - let key = ((y - track_bounds.origin.y) / px(1.0)).round() as i32; let active = active_match_index == Some(match_index); - if let Some(&spec_index) = spec_indexes_by_y.get(&key) { - let spec = &mut specs[spec_index]; + if last_row == Some(viewport_row) { + let spec = specs.last_mut().expect("a grouped marker must exist"); if active { spec.match_index = match_index; spec.active = true; @@ -287,15 +287,16 @@ pub(crate) fn scrollbar_marker_specs( continue; } - spec_indexes_by_y.insert(key, specs.len()); + if specs.len() >= limit { + break; + } + + last_row = Some(viewport_row); specs.push(ScrollbarMarkerSpec { match_index, y, active, }); - if specs.len() >= limit { - break; - } } specs @@ -344,89 +345,44 @@ pub(crate) fn scroll_offset_for_line_coord_centered( } pub(crate) fn search_match_index_for_scrollbar_hover( - track_bounds: Bounds, - total_lines: usize, - viewport_lines: usize, + viewport: ScrollbarMarkerViewport, matches: &[RangeInclusive], hover_y: Pixels, hit_radius: Pixels, ) -> Option { - if matches.is_empty() || hit_radius <= Pixels::ZERO || total_lines == 0 || viewport_lines == 0 { - return None; - } - - // Approximate the hovered line by inverting the minimap mapping. - let h = track_bounds.size.height; - if h <= Pixels::ZERO { + if matches.is_empty() || hit_radius <= Pixels::ZERO || viewport.viewport_lines == 0 { return None; } - debug_assert!( - matches - .windows(2) - .all(|pair| pair[0].start().line <= pair[1].start().line), - "scrollbar hover lookup expects search matches sorted by start line" - ); - - let mut t = (hover_y - track_bounds.origin.y) / h; - t = t.clamp(0.0, 1.0); - let idx = ((t * total_lines.max(1) as f32).floor() as i64) - .clamp(0, total_lines.saturating_sub(1) as i64) as usize; - let top_line = viewport_lines as i32 - total_lines as i32; - let approx_line = top_line.saturating_add(idx as i32); - - let i = matches.partition_point(|m| m.start().line < approx_line); - - let min_y = hover_y - hit_radius; - let max_y = hover_y + hit_radius; - - let mut best: Option<(usize, Pixels)> = None; - - // Scan down (increasing line -> increasing marker y) until we pass the hit window. - let mut j = i; - while j < matches.len() { - let line = matches[j].start().line; - let Some(y) = - scrollbar_marker_y_for_line_coord(track_bounds, total_lines, viewport_lines, line) - else { - break; - }; - if y > max_y { - break; - } - let dy = (y - hover_y).abs(); - if dy <= hit_radius && best.map(|(_, best_dy)| dy < best_dy).unwrap_or(true) { - best = Some((j, dy)); - if dy <= px(0.5) { - break; - } + let mut last_row = None; + let mut best = None; + for match_index in + visible_match_index_range(matches, viewport.display_offset, viewport.viewport_lines) + { + let viewport_row = match_viewport_row(&matches[match_index], viewport.display_offset); + if last_row == Some(viewport_row) { + continue; } - j += 1; - } + last_row = Some(viewport_row); - // Scan up. - let mut j = i; - while j > 0 { - j -= 1; - let line = matches[j].start().line; - let Some(y) = - scrollbar_marker_y_for_line_coord(track_bounds, total_lines, viewport_lines, line) - else { - break; + let Some(y) = marker_y_for_viewport_row( + viewport.lane_bounds, + viewport.line_height, + viewport.content_y_offset, + viewport_row, + ) else { + continue; }; - if y < min_y { - break; - } - let dy = (y - hover_y).abs(); - if dy <= hit_radius && best.map(|(_, best_dy)| dy < best_dy).unwrap_or(true) { - best = Some((j, dy)); - if dy <= px(0.5) { - break; - } + let distance = (y - hover_y).abs(); + if distance <= hit_radius + && best + .map(|(_, best_distance)| distance < best_distance) + .unwrap_or(true) + { + best = Some((match_index, distance)); } } - - best.map(|(idx, _)| idx) + best.map(|(match_index, _)| match_index) } impl TerminalView { @@ -549,14 +505,14 @@ impl TerminalView { self.scroll.scrollbar_revealed } - pub(crate) fn scrollbar_geometry(&self, cx: &App) -> ScrollbarGeometry { + pub(crate) fn scrollbar_bounds(&self, cx: &App) -> Bounds { let terminal = self.terminal.read(cx); let width = if TerminalSettings::global(cx).show_scrollbar { SCROLLBAR_WIDTH } else { Pixels::ZERO }; - scrollbar_geometry_for_terminal(terminal.last_content().terminal_bounds.bounds, width) + scrollbar_bounds_for_terminal(terminal.last_content().terminal_bounds.bounds, width) } pub(crate) fn sync_terminal_scrollbar_handle(&self, cx: &App) { @@ -697,16 +653,26 @@ impl TerminalView { pub(crate) fn update_scrollbar_preview_at( &mut self, - track: Bounds, + lane: Bounds, position: Point, cx: &mut Context, ) { let match_idx = { let terminal = self.terminal.read(cx); + let content = terminal.last_content(); + let content_y_offset = if content.display_offset == 0 { + self.scroll_top() + } else { + Pixels::ZERO + }; search_match_index_for_scrollbar_hover( - track, - terminal.total_lines(), - terminal.viewport_lines(), + ScrollbarMarkerViewport::new( + lane, + content.terminal_bounds.line_height, + content_y_offset, + content.display_offset, + terminal.viewport_lines(), + ), terminal.matches(), position.y, SCROLLBAR_MARKER_HIT_RADIUS, @@ -978,13 +944,28 @@ mod tests { use gpui_component::scroll::ScrollbarHandle as _; use super::{ - SCROLLBAR_MARKER_LIMIT, TerminalScrollbarHandle, buffer_index_for_line_coord, - scroll_offset_for_line_coord_centered, scrollbar_geometry_for_terminal, - scrollbar_marker_specs, scrollbar_marker_y_for_line_coord, + SCROLLBAR_MARKER_LIMIT, ScrollbarMarkerViewport, TerminalScrollbarHandle, + buffer_index_for_line_coord, scroll_offset_for_line_coord_centered, + scrollbar_bounds_for_terminal, scrollbar_marker_specs, search_match_index_for_scrollbar_hover, }; use crate::GridPoint; + fn marker_viewport( + lane_bounds: Bounds, + line_height: Pixels, + display_offset: usize, + viewport_lines: usize, + ) -> ScrollbarMarkerViewport { + ScrollbarMarkerViewport::new( + lane_bounds, + line_height, + Pixels::ZERO, + display_offset, + viewport_lines, + ) + } + #[test] fn terminal_scrollbar_handle_maps_display_offset_to_component_offset() { let handle = TerminalScrollbarHandle::default(); @@ -1028,52 +1009,84 @@ mod tests { } #[test] - fn scrollbar_geometry_for_terminal_returns_bounds_and_padded_track() { + fn scrollbar_bounds_for_terminal_returns_dedicated_lane_bounds() { let terminal_bounds = Bounds { origin: point(px(10.0), px(20.0)), size: size(px(80.0), px(100.0)), }; - let geometry = scrollbar_geometry_for_terminal(terminal_bounds, px(14.0)); + let bounds = scrollbar_bounds_for_terminal(terminal_bounds, px(14.0)); - assert_eq!(geometry.bounds.origin, point(px(76.0), px(20.0))); - assert_eq!(geometry.bounds.size, size(px(14.0), px(100.0))); - assert_eq!(geometry.track.origin, point(px(78.0), px(22.0))); - assert_eq!(geometry.track.size, size(px(10.0), px(96.0))); + assert_eq!(bounds.origin, point(px(90.0), px(20.0))); + assert_eq!(bounds.size, size(px(14.0), px(100.0))); } #[test] - fn scrollbar_marker_y_maps_entire_buffer_top_to_bottom() { - let track = Bounds { - origin: point(px(0.0), px(10.0)), - size: size(px(8.0), px(100.0)), + fn scrollbar_markers_align_with_visible_terminal_rows() { + let lane = Bounds { + origin: point(px(0.0), px(20.0)), + size: size(px(14.0), px(40.0)), }; + let matches = vec![ + GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), + GridPoint::new(-1, 0)..=GridPoint::new(-1, 1), + GridPoint::new(1, 0)..=GridPoint::new(1, 1), + GridPoint::new(2, 0)..=GridPoint::new(2, 1), + ]; - // total_lines=3, viewport_lines=1 -> top_line = -2, bottom_line = 0 - let y_top = scrollbar_marker_y_for_line_coord(track, 3, 1, -2).unwrap(); - let y_mid = scrollbar_marker_y_for_line_coord(track, 3, 1, -1).unwrap(); - let y_bot = scrollbar_marker_y_for_line_coord(track, 3, 1, 0).unwrap(); + let specs = scrollbar_marker_specs( + marker_viewport(lane, px(10.0), 2, 4), + &matches, + None, + SCROLLBAR_MARKER_LIMIT, + ); - // Line-band centers: 1/6, 3/6, 5/6 of the track height. - assert!(((y_top - px(26.666_7)) / px(1.0)).abs() < 0.01); - assert!(((y_mid - px(60.0)) / px(1.0)).abs() < 0.01); - assert!(((y_bot - px(93.333_3)) / px(1.0)).abs() < 0.01); + assert_eq!(specs.len(), 3); + assert_eq!( + specs.iter().map(|spec| spec.y).collect::>(), + vec![px(25.0), px(35.0), px(55.0)] + ); } #[test] - fn scrollbar_marker_y_clamps_out_of_range_lines() { - let track = Bounds { - origin: point(px(0.0), px(0.0)), - size: size(px(8.0), px(50.0)), + fn scrollbar_markers_follow_terminal_content_pixel_offset() { + let lane = Bounds { + origin: point(px(0.0), px(20.0)), + size: size(px(14.0), px(40.0)), }; + let matches = vec![GridPoint::new(0, 0)..=GridPoint::new(0, 1)]; - // total_lines=3, viewport_lines=1 -> valid coords: -2,-1,0. - let y_above = scrollbar_marker_y_for_line_coord(track, 3, 1, -99).unwrap(); - let y_below = scrollbar_marker_y_for_line_coord(track, 3, 1, 99).unwrap(); + let specs = scrollbar_marker_specs( + ScrollbarMarkerViewport::new(lane, px(10.0), px(3.0), 0, 4), + &matches, + None, + SCROLLBAR_MARKER_LIMIT, + ); - // Clamps to the first/last line-band centers (1/6 and 5/6 of the height). - assert!(((y_above - px(8.333_3)) / px(1.0)).abs() < 0.01); - assert!(((y_below - px(41.666_7)) / px(1.0)).abs() < 0.01); + assert_eq!(specs[0].y, px(22.0)); + } + + #[test] + fn scrollbar_markers_exclude_rows_shifted_out_of_view() { + let lane = Bounds { + origin: point(px(0.0), px(20.0)), + size: size(px(14.0), px(40.0)), + }; + let matches = vec![ + GridPoint::new(0, 0)..=GridPoint::new(0, 1), + GridPoint::new(1, 0)..=GridPoint::new(1, 1), + ]; + + let specs = scrollbar_marker_specs( + ScrollbarMarkerViewport::new(lane, px(10.0), px(11.0), 0, 4), + &matches, + None, + SCROLLBAR_MARKER_LIMIT, + ); + + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].match_index, 1); + assert_eq!(specs[0].y, px(24.0)); } #[test] @@ -1104,22 +1117,31 @@ mod tests { #[test] fn search_match_index_for_scrollbar_hover_picks_nearest_marker() { - let track = Bounds { + let lane = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(10.0), px(100.0)), }; - // total_lines=3, viewport_lines=1 => marker y ~ 16.7, 50, 83.3 let matches = vec![ GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), GridPoint::new(-1, 0)..=GridPoint::new(-1, 1), GridPoint::new(0, 0)..=GridPoint::new(0, 1), ]; - let hit = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(6.0)); - assert_eq!(hit, Some(2)); + let hit = search_match_index_for_scrollbar_hover( + marker_viewport(lane, px(10.0), 2, 3), + &matches, + px(16.0), + px(6.0), + ); + assert_eq!(hit, Some(1)); - let miss = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(0.5)); + let miss = search_match_index_for_scrollbar_hover( + marker_viewport(lane, px(10.0), 2, 3), + &matches, + px(50.0), + px(0.5), + ); assert_eq!(miss, None); } @@ -1134,7 +1156,32 @@ mod tests { GridPoint::new(-1, 4)..=GridPoint::new(-1, 5), ]; - let specs = scrollbar_marker_specs(track, 3, 1, &matches, Some(1), SCROLLBAR_MARKER_LIMIT); + let specs = scrollbar_marker_specs( + marker_viewport(track, px(10.0), 1, 3), + &matches, + Some(1), + SCROLLBAR_MARKER_LIMIT, + ); + + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].match_index, 1); + assert!(specs[0].active); + } + + #[test] + fn scrollbar_marker_limit_keeps_active_duplicate_on_last_included_row() { + let lane = Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(10.0), px(100.0)), + }; + let matches = vec![ + GridPoint::new(0, 0)..=GridPoint::new(0, 1), + GridPoint::new(0, 4)..=GridPoint::new(0, 5), + GridPoint::new(1, 0)..=GridPoint::new(1, 1), + ]; + + let specs = + scrollbar_marker_specs(marker_viewport(lane, px(10.0), 0, 3), &matches, Some(1), 1); assert_eq!(specs.len(), 1); assert_eq!(specs[0].match_index, 1); @@ -1154,7 +1201,12 @@ mod tests { GridPoint::new(0, 0)..=GridPoint::new(0, 1), ]; - let specs = scrollbar_marker_specs(track, 3, 1, &matches, Some(2), SCROLLBAR_MARKER_LIMIT); + let specs = scrollbar_marker_specs( + marker_viewport(track, px(10.0), 2, 3), + &matches, + Some(2), + SCROLLBAR_MARKER_LIMIT, + ); assert_eq!(specs.len(), 3); assert_eq!(specs[0].match_index, 0); @@ -1175,7 +1227,8 @@ mod tests { GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), ]; - let specs = scrollbar_marker_specs(track, 5, 1, &matches, Some(2), 2); + let specs = + scrollbar_marker_specs(marker_viewport(track, px(10.0), 4, 5), &matches, Some(2), 2); assert_eq!(specs.len(), 2); assert_eq!(specs[0].match_index, 0); diff --git a/crates/gpui_term/src/view/search.rs b/crates/gpui_term/src/view/search.rs index 742d274..8973060 100644 --- a/crates/gpui_term/src/view/search.rs +++ b/crates/gpui_term/src/view/search.rs @@ -52,8 +52,8 @@ fn on_search_backdrop_left_mouse_down( cx: &mut Context, ) { if TerminalSettings::global(cx).show_scrollbar { - let geometry = this.scrollbar_geometry(cx); - if geometry.bounds.contains(&e.position) { + let scrollbar_bounds = this.scrollbar_bounds(cx); + if scrollbar_bounds.contains(&e.position) { // Allow scrollbar interaction while searching; do not dismiss. this.set_scrollbar_hovered(true, cx); this.set_mouse_left_down_in_terminal(false); @@ -75,9 +75,9 @@ fn on_search_backdrop_mouse_move( let panel_dragging = this.search.search_panel_dragging; if !panel_dragging { if TerminalSettings::global(cx).show_scrollbar { - let geometry = this.scrollbar_geometry(cx); - if geometry.bounds.contains(&e.position) { - this.update_scrollbar_preview_at(geometry.track, e.position, cx); + let scrollbar_bounds = this.scrollbar_bounds(cx); + if scrollbar_bounds.contains(&e.position) { + this.update_scrollbar_preview_at(scrollbar_bounds, e.position, cx); } else { this.clear_scrollbar_preview(cx); } @@ -148,8 +148,8 @@ fn on_search_backdrop_right_mouse_down( cx: &mut Context, ) { if TerminalSettings::global(cx).show_scrollbar { - let geometry = this.scrollbar_geometry(cx); - if geometry.bounds.contains(&e.position) { + let scrollbar_bounds = this.scrollbar_bounds(cx); + if scrollbar_bounds.contains(&e.position) { // Do not dismiss on scrollbar right-click either. cx.stop_propagation(); return; From c348403cb79af4e206ad89313be1355ab15f31b7 Mon Sep 17 00:00:00 2001 From: iamazy Date: Tue, 4 Aug 2026 19:16:00 +0800 Subject: [PATCH 8/8] fix(lock-screen): show titlebar when locking screen --- termua/src/lock_screen/view.rs | 33 ++--------------------- termua/src/window/main_window/render.rs | 35 +++++++++++++++++++++---- termua/src/window/main_window/tests.rs | 12 +++++++-- termua/src/window/new_session/render.rs | 22 ++++++++++------ termua/src/window/new_session/tests.rs | 4 --- termua/src/window/settings/view.rs | 5 ++-- 6 files changed, 59 insertions(+), 52 deletions(-) diff --git a/termua/src/lock_screen/view.rs b/termua/src/lock_screen/view.rs index 5e83b9f..2da1873 100644 --- a/termua/src/lock_screen/view.rs +++ b/termua/src/lock_screen/view.rs @@ -1,7 +1,6 @@ use gpui::{ - AnyElement, App, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement, - MouseButton, ParentElement as _, SharedString, Styled, Window, div, - prelude::FluentBuilder as _, + AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, SharedString, Styled, Window, div, prelude::FluentBuilder as _, }; use gpui_component::{ ActiveTheme as _, IconName, Sizable as _, @@ -76,19 +75,6 @@ pub fn render_lock_overlay( unlock: fn(&mut T, &mut Window, &mut Context), cx: &mut Context, ) -> AnyElement { - let left_inset = if cfg!(target_os = "macos") { - // Leave room for macOS traffic-light window controls. - gpui::px(84.) - } else { - gpui::px(0.) - }; - let right_inset = if cfg!(target_os = "macos") { - gpui::px(0.) - } else { - // Leave room for right-side window controls. - gpui::px(140.) - }; - div() .id("termua-lock-overlay") .debug_selector(|| "termua-lock-overlay".to_string()) @@ -147,20 +133,5 @@ pub fn render_lock_overlay( ), ), ) - .child( - div() - .absolute() - .top_0() - .left(left_inset) - .right(right_inset) - .h(gpui_component::TITLE_BAR_HEIGHT) - .debug_selector(|| "termua-lock-drag-overlay".to_string()) - .cursor(CursorStyle::OpenHand) - .on_mouse_down(MouseButton::Left, |_ev, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - window.start_window_move(); - }), - ) .into_any_element() } diff --git a/termua/src/window/main_window/render.rs b/termua/src/window/main_window/render.rs index 4c6dea5..493b61b 100644 --- a/termua/src/window/main_window/render.rs +++ b/termua/src/window/main_window/render.rs @@ -67,12 +67,39 @@ impl TermuaWindow { .child(self.dock_area.clone()) .into_any_element() } + + fn render_main_content( + &mut self, + window: &mut Window, + lock_overlay: Option, + cx: &mut Context, + ) -> gpui::AnyElement { + let center = self.render_center_area(window, cx); + + v_flex() + .flex_1() + .min_h_0() + .relative() + .child(center) + .child(self.footbar.clone()) + .when_some(lock_overlay, |this, overlay| this.child(overlay)) + .into_any_element() + } } impl Render for TermuaWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let lock_overlay = self.render_lock_overlay(cx); - let center = self.render_center_area(window, cx); + let locked = cx.global::().locked(); + let titlebar = if locked { + gpui_component::TitleBar::new() + } else { + MenubarTitleBar::build(window, cx) + }; + let titlebar = div() + .debug_selector(|| "termua-window-titlebar".to_string()) + .child(titlebar); + let main_content = self.render_main_content(window, lock_overlay, cx); v_flex() .size_full() @@ -98,12 +125,10 @@ impl Render for TermuaWindow { .on_action(cx.listener(Self::on_new_local_terminal)) .on_action(cx.listener(Self::on_play_cast)) .on_action(cx.listener(Self::on_open_sftp)) - .child(MenubarTitleBar::build(window, cx)) - .child(center) - .child(self.footbar.clone()) + .child(titlebar) + .child(main_content) .children(gpui_component::Root::render_sheet_layer(window, cx)) .children(gpui_component::Root::render_dialog_layer(window, cx)) .children(gpui_component::Root::render_notification_layer(window, cx)) - .when_some(lock_overlay, |this, overlay| this.child(overlay)) } } diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 92b520b..d6eb79f 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -1510,9 +1510,17 @@ fn main_window_renders_lock_overlay_when_locked(cx: &mut gpui::TestAppContext) { window.run_until_parked(); assert!(window.debug_bounds("termua-lock-overlay").is_some()); + let overlay_bounds = window + .debug_bounds("termua-lock-overlay") + .expect("expected lock overlay bounds"); + assert_eq!(overlay_bounds.origin.y, gpui_component::TITLE_BAR_HEIGHT); assert!( - window.debug_bounds("termua-lock-drag-overlay").is_some(), - "expected a drag overlay so the window remains movable while locked" + window.debug_bounds("termua-window-titlebar").is_some(), + "titlebar should remain visible while locked so window controls remain available" + ); + assert!( + window.debug_bounds("foldable-app-menu-bar").is_none(), + "in-window menu should be hidden while locked" ); assert!(window.debug_bounds("termua-lock-password-input").is_some()); } diff --git a/termua/src/window/new_session/render.rs b/termua/src/window/new_session/render.rs index 1603f0c..4926a97 100644 --- a/termua/src/window/new_session/render.rs +++ b/termua/src/window/new_session/render.rs @@ -132,21 +132,27 @@ impl Render for NewSessionWindow { .child(div().text_sm().child(title)), ), ) - .child(self.render_protocol_tabs(window, cx)) .child( - h_flex() - .id("termua-new-session-main") + v_flex() .flex_1() .min_h_0() - .items_stretch() - .child(self.render_left_pane(window, cx)) - .child(self.render_right_pane(window, cx)), + .relative() + .child(self.render_protocol_tabs(window, cx)) + .child( + h_flex() + .id("termua-new-session-main") + .flex_1() + .min_h_0() + .items_stretch() + .child(self.render_left_pane(window, cx)) + .child(self.render_right_pane(window, cx)), + ) + .child(self.render_footer(connect_enabled, window, cx)) + .when_some(lock_overlay, |this, overlay| this.child(overlay)), ) - .child(self.render_footer(connect_enabled, window, cx)) .children(gpui_component::Root::render_sheet_layer(window, cx)) .children(gpui_component::Root::render_dialog_layer(window, cx)) .children(gpui_component::Root::render_notification_layer(window, cx)) - .when_some(lock_overlay, |this, overlay| this.child(overlay)) } } diff --git a/termua/src/window/new_session/tests.rs b/termua/src/window/new_session/tests.rs index 3243057..cf1243c 100644 --- a/termua/src/window/new_session/tests.rs +++ b/termua/src/window/new_session/tests.rs @@ -221,10 +221,6 @@ fn new_session_renders_lock_overlay_when_locked(cx: &mut gpui::TestAppContext) { window.debug_bounds("termua-lock-overlay").is_some(), "expected New Session to render the lock overlay while locked" ); - assert!( - window.debug_bounds("termua-lock-drag-overlay").is_some(), - "expected a drag overlay so the window remains movable while locked" - ); assert!(window.debug_bounds("termua-lock-password-input").is_some()); } diff --git a/termua/src/window/settings/view.rs b/termua/src/window/settings/view.rs index 32a8121..ae3609a 100644 --- a/termua/src/window/settings/view.rs +++ b/termua/src/window/settings/view.rs @@ -1918,18 +1918,19 @@ impl Render for SettingsWindow { h_flex() .flex_1() .min_h_0() + .relative() // `h_flex()` defaults to `items_center()`, which causes panes to size to their // content height (breaking scroll because the scroll container can grow). // We need the panes to stretch to the available height. .items_stretch() .child(self.render_left_pane(window, cx)) - .child(self.render_right_pane(window, cx)), + .child(self.render_right_pane(window, cx)) + .when_some(lock_overlay, |this, overlay| this.child(overlay)), ) .children(gpui_component::Root::render_sheet_layer(window, cx)) .children(gpui_component::Root::render_dialog_layer(window, cx)) .when_some(drag_overlay, |this, overlay| this.child(overlay)) .children(gpui_component::Root::render_notification_layer(window, cx)) - .when_some(lock_overlay, |this, overlay| this.child(overlay)) } }