diff --git a/AGENTS.md b/AGENTS.md
index bda195a..14c6d3e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -130,6 +130,7 @@ When handing work off to another person or to the user, include:
- Favor explicitness over cleverness.
- Reuse existing patterns already present in the workspace.
- Prefer modular, composable changes over tightly coupled one-off implementations.
+- Avoid glob imports such as `use foo::*` or `use super::*`; import the specific items needed.
- Add tests near the affected code when the repository already has a local testing pattern for that area.
- Put unit tests at the bottom of the file when adding or updating inline tests.
- Preserve consistency with existing naming, error handling, and module organization.
diff --git a/Cargo.lock b/Cargo.lock
index 21344fe..3fd9dec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7285,6 +7285,7 @@ dependencies = [
"smol",
"termua_relay",
"termua_zeroclaw",
+ "thiserror 2.0.17",
"wezterm-ssh",
"windows 0.62.2",
]
diff --git a/assets/icons/fish.svg b/assets/icons/fish.svg
deleted file mode 100644
index da15e41..0000000
--- a/assets/icons/fish.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/assets/icons/nushell.svg b/assets/icons/nushell.svg
deleted file mode 100644
index 2bb157a..0000000
--- a/assets/icons/nushell.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/assets/icons/sh.svg b/assets/icons/sh.svg
deleted file mode 100644
index 3c7fc18..0000000
--- a/assets/icons/sh.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/assets/shell/termua-env.nu b/assets/shell/termua-env.nu
deleted file mode 100644
index 6d6af77..0000000
--- a/assets/shell/termua-env.nu
+++ /dev/null
@@ -1,8 +0,0 @@
-# Termua OSC 133 environment integration (nushell).
-#
-# This file is written as the temporary `env.nu`.
-
-const __termua_orig_env = __TERMUA_ORIG_ENV__
-source-env $__termua_orig_env
-
-$env.TERMUA_OSC133_COMMAND_ACTIVE = false
diff --git a/assets/shell/termua-osc133.fish b/assets/shell/termua-osc133.fish
deleted file mode 100644
index e50af3d..0000000
--- a/assets/shell/termua-osc133.fish
+++ /dev/null
@@ -1,47 +0,0 @@
-# Termua OSC 133 shell integration (fish).
-#
-# Emits a minimal subset of OSC 133 markers:
-# - A: prompt start
-# - B: prompt end
-# - C: command start
-# - D;: command end
-
-status is-interactive; or return 0
-
-set -g fish_handle_reflow 0
-
-if set -q TERMUA_OSC133_FISH_INSTALLED
- return 0
-end
-
-set -g TERMUA_OSC133_FISH_INSTALLED 1
-
-function __termua_osc133_print --argument-names payload
- printf '\e]133;%s\a' "$payload"
-end
-
-if functions -q fish_prompt
- functions -c fish_prompt __termua_osc133_orig_fish_prompt
-else
- function __termua_osc133_orig_fish_prompt
- printf '> '
- end
-end
-
-function fish_prompt
- __termua_osc133_print "A"
- __termua_osc133_orig_fish_prompt
-end
-
-function __termua_osc133_preexec --on-event fish_preexec
- set -g TERMUA_OSC133_COMMAND_ACTIVE 1
- __termua_osc133_print "B"
- __termua_osc133_print "C"
-end
-
-function __termua_osc133_postexec --on-event fish_postexec
- if set -q TERMUA_OSC133_COMMAND_ACTIVE
- __termua_osc133_print "D;$status"
- set -e TERMUA_OSC133_COMMAND_ACTIVE
- end
-end
diff --git a/assets/shell/termua-osc133.nu b/assets/shell/termua-osc133.nu
deleted file mode 100644
index dc5438b..0000000
--- a/assets/shell/termua-osc133.nu
+++ /dev/null
@@ -1,30 +0,0 @@
-# Termua OSC 133 shell integration (nushell).
-#
-# This file is written as the temporary `config.nu`. It first sources the user's
-# real Nushell config, then appends Termua's OSC 133 hooks.
-
-const __termua_orig_config = __TERMUA_ORIG_CONFIG__
-source $__termua_orig_config
-
-let __termua_pre_prompt = ($env.config | get -o hooks.pre_prompt | default [])
-let __termua_pre_execution = ($env.config | get -o hooks.pre_execution | default [])
-$env.config = (
- $env.config
- | upsert hooks.pre_prompt (
- $__termua_pre_prompt | append {||
- if ($env.TERMUA_OSC133_COMMAND_ACTIVE? | default false) {
- let __termua_exit = ($env.LAST_EXIT_CODE? | default 0)
- print -n $"\u{1b}]133;D;($__termua_exit)\u{7}"
- load-env { TERMUA_OSC133_COMMAND_ACTIVE: false }
- }
- print -n "\u{1b}]133;A\u{7}"
- }
- )
- | upsert hooks.pre_execution (
- $__termua_pre_execution | append {||
- print -n "\u{1b}]133;B\u{7}"
- print -n "\u{1b}]133;C\u{7}"
- load-env { TERMUA_OSC133_COMMAND_ACTIVE: true }
- }
- )
-)
diff --git a/crates/gpui_common/src/assets.rs b/crates/gpui_common/src/assets.rs
index c91423a..f198f1c 100644
--- a/crates/gpui_common/src/assets.rs
+++ b/crates/gpui_common/src/assets.rs
@@ -125,7 +125,6 @@ mod tests {
TermuaIcon::FolderOpenBlue,
TermuaIcon::FolderClosedBlue,
TermuaIcon::GitBash,
- TermuaIcon::Nushell,
TermuaIcon::Pwsh,
] {
assert!(
diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs
index e83cdfe..cacd076 100644
--- a/crates/gpui_term/src/backends/alacritty/mod.rs
+++ b/crates/gpui_term/src/backends/alacritty/mod.rs
@@ -62,7 +62,7 @@ mod serial;
pub mod ssh;
fn local_pty_options_for_program_exists(
- env: std::collections::HashMap,
+ env: HashMap,
program_exists: impl FnOnce(&str) -> bool,
) -> Options {
let shell_program = crate::shell::pick_shell_program_from_env(&env);
@@ -1861,33 +1861,33 @@ mod shell_tests {
#[test]
fn local_pty_options_uses_termua_shell_when_available() {
let mut env = std::collections::HashMap::new();
- env.insert("TERMUA_SHELL".to_string(), "fish".to_string());
+ env.insert("TERMUA_SHELL".to_string(), "bash".to_string());
- let opts = local_pty_options_for_program_exists(env, |p| p == "fish");
+ let opts = local_pty_options_for_program_exists(env, |p| p == "bash");
assert!(opts.shell.is_some());
}
#[test]
fn local_pty_options_uses_shell_integration_args() {
let mut env = std::collections::HashMap::new();
- env.insert("TERMUA_SHELL".to_string(), "fish".to_string());
+ env.insert("TERMUA_SHELL".to_string(), "pwsh".to_string());
env.insert(
- "TERMUA_FISH_INIT".to_string(),
- "/tmp/termua-test.fish".to_string(),
+ "TERMUA_PWSH_INIT".to_string(),
+ "/tmp/termua-test.ps1".to_string(),
);
- let opts = local_pty_options_for_program_exists(env, |p| p == "fish");
+ let opts = local_pty_options_for_program_exists(env, |p| p == "pwsh");
let shell = opts.shell.expect("expected shell");
let shell_debug = format!("{shell:?}");
- assert!(shell_debug.contains("fish"));
- assert!(shell_debug.contains("--init-command"));
- assert!(shell_debug.contains("source \\\"$TERMUA_FISH_INIT\\\""));
- assert!(shell_debug.contains("--interactive"));
+ assert!(shell_debug.contains("pwsh"));
+ assert!(shell_debug.contains("-NoLogo"));
+ assert!(shell_debug.contains("-NoExit"));
+ assert!(shell_debug.contains(". \\\"$env:TERMUA_PWSH_INIT\\\""));
}
#[test]
fn local_pty_options_falls_back_when_shell_not_found() {
- let mut env = std::collections::HashMap::new();
+ let mut env = HashMap::new();
env.insert("TERMUA_SHELL".to_string(), "fish".to_string());
let opts = local_pty_options_for_program_exists(env, |_p| false);
diff --git a/crates/gpui_term/src/backends/wezterm/mod.rs b/crates/gpui_term/src/backends/wezterm/mod.rs
index 512e375..9746930 100644
--- a/crates/gpui_term/src/backends/wezterm/mod.rs
+++ b/crates/gpui_term/src/backends/wezterm/mod.rs
@@ -3284,66 +3284,6 @@ mod tests {
);
}
- #[test]
- fn local_shell_candidates_uses_fish_init_when_configured() {
- let mut env = std::collections::HashMap::new();
- env.insert("TERMUA_SHELL".to_string(), "fish".to_string());
- env.insert(
- "TERMUA_FISH_INIT".to_string(),
- "/tmp/termua-test.fish".to_string(),
- );
-
- let candidates = super::shell_command_candidates_for_local_env(&env);
- let argv: Vec = candidates[0]
- .get_argv()
- .iter()
- .map(|s| s.to_string_lossy().to_string())
- .collect();
-
- assert_eq!(
- argv,
- vec![
- "fish",
- "--init-command",
- "source \"$TERMUA_FISH_INIT\"",
- "--interactive"
- ]
- );
- }
-
- #[test]
- fn local_shell_candidates_uses_nu_configs_when_configured() {
- let mut env = std::collections::HashMap::new();
- env.insert("TERMUA_SHELL".to_string(), "nu".to_string());
- env.insert(
- "TERMUA_NU_CONFIG".to_string(),
- "/tmp/termua-config.nu".to_string(),
- );
- env.insert(
- "TERMUA_NU_ENV_CONFIG".to_string(),
- "/tmp/termua-env.nu".to_string(),
- );
-
- let candidates = super::shell_command_candidates_for_local_env(&env);
- let argv: Vec = candidates[0]
- .get_argv()
- .iter()
- .map(|s| s.to_string_lossy().to_string())
- .collect();
-
- assert_eq!(
- argv,
- vec![
- "nu",
- "--config",
- "/tmp/termua-config.nu",
- "--env-config",
- "/tmp/termua-env.nu",
- "--interactive"
- ]
- );
- }
-
#[test]
fn local_shell_candidates_uses_powershell_init_when_configured() {
let mut env = std::collections::HashMap::new();
diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs
index 60e434a..4afcdf5 100644
--- a/crates/gpui_term/src/element.rs
+++ b/crates/gpui_term/src/element.rs
@@ -33,7 +33,7 @@ use crate::{
reserve_left_padding_without_line_numbers, should_relayout_for_mode_change,
should_show_line_numbers,
},
- scrollbar::{
+ scrolling::{
SCROLLBAR_WIDTH, ScrollbarLayoutState, overlay_scrollbar_layout_state,
paint_overlay_scrollbar, scroll_offset_for_drag_delta,
scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y,
@@ -3665,7 +3665,10 @@ fn terminal_view_bounds_for_suggestions_overlay(
#[cfg(test)]
mod suggestions_overlay_desc_tests {
- use super::*;
+ use gpui::{Bounds, point, px, size};
+
+ use super::{SuggestionsOverlayHitTest, compute_suggestions_overlay_layout};
+ use crate::terminal::TerminalBounds;
#[test]
fn layout_does_not_reserve_desc_row_when_empty() {
@@ -3882,7 +3885,16 @@ mod suggestions_overlay_desc_tests {
#[cfg(test)]
mod tests {
- use super::*;
+ use std::ops::RangeInclusive;
+
+ use gpui::{Bounds, point, px, size};
+ use gpui_component::Theme;
+
+ use super::{
+ compute_terminal_layout_metrics, highlight_quads_for_range, placeholder_highlight_bgs,
+ snippet_placeholder_bg_quads,
+ };
+ use crate::{GridPoint, TerminalMode, view::line_number::should_relayout_for_mode_change};
#[test]
fn placeholder_highlight_colors_are_theme_derived() {
diff --git a/crates/gpui_term/src/shell.rs b/crates/gpui_term/src/shell.rs
index 07b0e38..a0e63a1 100644
--- a/crates/gpui_term/src/shell.rs
+++ b/crates/gpui_term/src/shell.rs
@@ -3,18 +3,15 @@ use std::collections::HashMap;
pub const SHELL_ENV_KEY: &str = "SHELL";
pub const TERMUA_SHELL_ENV_KEY: &str = "TERMUA_SHELL";
pub const TERMUA_BASH_RCFILE_ENV_KEY: &str = "TERMUA_BASH_RCFILE";
-pub const TERMUA_FISH_INIT_ENV_KEY: &str = "TERMUA_FISH_INIT";
-pub const TERMUA_NU_CONFIG_ENV_KEY: &str = "TERMUA_NU_CONFIG";
-pub const TERMUA_NU_ENV_CONFIG_ENV_KEY: &str = "TERMUA_NU_ENV_CONFIG";
pub const TERMUA_PWSH_INIT_ENV_KEY: &str = "TERMUA_PWSH_INIT";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShellKind {
Bash,
Zsh,
- Fish,
- Nu,
+ Pwsh,
PowerShell,
+ Cmd,
Other,
}
@@ -52,9 +49,9 @@ pub fn shell_kind(program: &str) -> ShellKind {
match name {
"bash" => ShellKind::Bash,
"zsh" => ShellKind::Zsh,
- "fish" => ShellKind::Fish,
- "nu" | "nushell" => ShellKind::Nu,
- "pwsh" | "powershell" => ShellKind::PowerShell,
+ "pwsh" => ShellKind::Pwsh,
+ "powershell" => ShellKind::PowerShell,
+ "cmd" => ShellKind::Cmd,
_ => ShellKind::Other,
}
}
@@ -63,9 +60,8 @@ pub fn shell_display_name(program: &str) -> String {
match shell_kind(program) {
ShellKind::Bash => "bash".to_string(),
ShellKind::Zsh => "zsh".to_string(),
- ShellKind::Fish => "fish".to_string(),
- ShellKind::Nu => "nushell".to_string(),
- ShellKind::PowerShell => "powershell".to_string(),
+ ShellKind::Pwsh | ShellKind::PowerShell => "powershell".to_string(),
+ ShellKind::Cmd => "cmd".to_string(),
ShellKind::Other => std::path::Path::new(program.trim())
.file_name()
.and_then(|s| s.to_str())
@@ -100,117 +96,37 @@ pub fn shell_integration_args_for_env(program: &str, env: &HashMap env
- .get(TERMUA_FISH_INIT_ENV_KEY)
- .map(|s| s.trim())
- .filter(|s| !s.is_empty())
- .map(|_init| {
- vec![
- "--init-command".to_string(),
- "source \"$TERMUA_FISH_INIT\"".to_string(),
- "--interactive".to_string(),
- ]
- })
- .unwrap_or_default(),
- ShellKind::Nu => {
- let config = env
- .get(TERMUA_NU_CONFIG_ENV_KEY)
- .map(|s| s.trim())
- .filter(|s| !s.is_empty());
- let env_config = env
- .get(TERMUA_NU_ENV_CONFIG_ENV_KEY)
- .map(|s| s.trim())
- .filter(|s| !s.is_empty());
-
- match (config, env_config) {
- (Some(config), Some(env_config)) => vec![
- "--config".to_string(),
- config.to_string(),
- "--env-config".to_string(),
- env_config.to_string(),
- "--interactive".to_string(),
- ],
- _ => Vec::new(),
- }
- }
- ShellKind::PowerShell => env
+ ShellKind::Pwsh => env
.get(TERMUA_PWSH_INIT_ENV_KEY)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|_init| powershell_integration_args(cfg!(windows)))
.unwrap_or_default(),
- ShellKind::Zsh | ShellKind::Other => Vec::new(),
+ ShellKind::Zsh | ShellKind::PowerShell | ShellKind::Cmd | ShellKind::Other => Vec::new(),
}
}
-fn shell_program_candidates_for_windows() -> &'static [&'static str] {
- &["pwsh", "powershell", "cmd"]
-}
-
pub fn shell_program_candidates() -> &'static [&'static str] {
if cfg!(windows) {
// Windows: prefer PowerShell 7+ when available, then Windows PowerShell, then cmd.
- shell_program_candidates_for_windows()
+ &["pwsh", "powershell", "cmd"]
} else if cfg!(target_os = "macos") {
// macOS: default user shell is zsh on modern macOS.
- &["zsh", "bash", "fish", "nu", "pwsh", "sh"]
+ &["zsh", "bash", "pwsh"]
} else {
// Linux/*nix: bash is commonly available and expected.
- &["bash", "zsh", "fish", "nu", "pwsh", "sh"]
+ &["bash", "zsh", "pwsh"]
}
}
-pub fn fallback_shell_program() -> &'static str {
+pub fn default_shell_program() -> &'static str {
if cfg!(windows) {
- "powershell"
+ "pwsh"
} else if cfg!(target_os = "macos") {
"zsh"
- } else if cfg!(target_os = "linux") {
- "bash"
} else {
- "sh"
- }
-}
-
-pub fn shell_program_items_for_program_exists(
- mut program_exists: impl FnMut(&str) -> bool,
-) -> Vec {
- shell_program_items_for_candidates(
- shell_program_candidates(),
- fallback_shell_program(),
- &mut program_exists,
- )
-}
-
-fn shell_program_items_for_candidates(
- candidates: &[&str],
- fallback: &str,
- mut program_exists: impl FnMut(&str) -> bool,
-) -> Vec {
- let mut items = Vec::new();
-
- let pwsh_exists = candidates.contains(&"pwsh") && program_exists("pwsh");
- for candidate in candidates {
- if *candidate == "pwsh" {
- if pwsh_exists {
- items.push((*candidate).to_string());
- }
- } else if *candidate == "powershell" && pwsh_exists {
- continue;
- } else if program_exists(candidate) {
- items.push((*candidate).to_string());
- }
- }
-
- if items.is_empty() {
- items.push(fallback.to_string());
+ "bash"
}
-
- items
-}
-
-pub fn shell_program_items() -> Vec {
- shell_program_items_for_program_exists(program_exists_on_path)
}
#[cfg(any(windows, test))]
@@ -312,74 +228,53 @@ mod tests {
fn shell_kind_detects_supported_shells() {
assert_eq!(shell_kind("/bin/bash"), ShellKind::Bash);
assert_eq!(shell_kind("zsh"), ShellKind::Zsh);
- assert_eq!(shell_kind("fish"), ShellKind::Fish);
- assert_eq!(shell_kind("nu"), ShellKind::Nu);
- assert_eq!(shell_kind("pwsh"), ShellKind::PowerShell);
+ assert_eq!(shell_kind("pwsh"), ShellKind::Pwsh);
assert_eq!(shell_kind("powershell"), ShellKind::PowerShell);
+ assert_eq!(shell_kind("cmd"), ShellKind::Cmd);
assert_eq!(shell_kind("unknown"), ShellKind::Other);
}
#[test]
fn shell_display_name_normalizes_supported_shells() {
assert_eq!(shell_display_name("/bin/bash"), "bash");
- assert_eq!(shell_display_name("nu"), "nushell");
assert_eq!(shell_display_name("pwsh"), "powershell");
assert_eq!(shell_display_name("powershell"), "powershell");
- assert_eq!(shell_display_name("/opt/bin/fish"), "fish");
- assert_eq!(shell_display_name("xonsh"), "xonsh");
}
#[test]
- fn shell_integration_args_build_for_fish() {
- let mut env = HashMap::new();
- env.insert(
- TERMUA_FISH_INIT_ENV_KEY.to_string(),
- "/tmp/it doesn't matter.fish".to_string(),
- );
- assert_eq!(
- shell_integration_args_for_env("fish", &env),
- vec![
- "--init-command".to_string(),
- "source \"$TERMUA_FISH_INIT\"".to_string(),
- "--interactive".to_string(),
- ]
- );
+ fn ui_default_shell_matches_platform_policy() {
+ #[cfg(windows)]
+ assert_eq!(default_shell_program(), "pwsh");
+
+ #[cfg(target_os = "macos")]
+ assert_eq!(default_shell_program(), "zsh");
+
+ #[cfg(all(not(windows), not(target_os = "macos")))]
+ assert_eq!(default_shell_program(), "bash");
}
#[test]
- fn shell_integration_args_build_for_nu() {
+ fn shell_integration_args_build_for_pwsh() {
let mut env = HashMap::new();
env.insert(
- TERMUA_NU_CONFIG_ENV_KEY.to_string(),
- "/tmp/config.nu".to_string(),
- );
- env.insert(
- TERMUA_NU_ENV_CONFIG_ENV_KEY.to_string(),
- "/tmp/env.nu".to_string(),
+ TERMUA_PWSH_INIT_ENV_KEY.to_string(),
+ "/tmp/init.ps1".to_string(),
);
assert_eq!(
- shell_integration_args_for_env("nu", &env),
- vec![
- "--config".to_string(),
- "/tmp/config.nu".to_string(),
- "--env-config".to_string(),
- "/tmp/env.nu".to_string(),
- "--interactive".to_string(),
- ]
+ shell_integration_args_for_env("pwsh", &env),
+ powershell_integration_args(cfg!(windows))
);
}
#[test]
- fn shell_integration_args_build_for_powershell() {
+ fn shell_integration_args_do_not_build_for_windows_powershell() {
let mut env = HashMap::new();
env.insert(
TERMUA_PWSH_INIT_ENV_KEY.to_string(),
"/tmp/init.ps1".to_string(),
);
- assert_eq!(
- shell_integration_args_for_env("pwsh", &env),
- powershell_integration_args(cfg!(windows))
- );
+
+ assert!(shell_integration_args_for_env("powershell", &env).is_empty());
}
#[test]
@@ -397,16 +292,6 @@ mod tests {
);
}
- #[test]
- fn shell_program_items_for_program_exists_filters_candidates() {
- let candidates = shell_program_candidates();
- let keep_a = candidates.first().copied().unwrap();
- let keep_b = candidates.last().copied().unwrap();
-
- let items = shell_program_items_for_program_exists(|name| name == keep_a || name == keep_b);
- assert_eq!(items, vec![keep_a.to_string(), keep_b.to_string()]);
- }
-
#[test]
fn platform_shell_candidates_are_ordered_by_preference() {
let candidates = shell_program_candidates();
@@ -421,36 +306,6 @@ mod tests {
assert_eq!(candidates.first().copied(), Some("bash"));
}
- #[test]
- fn windows_shell_candidates_prefer_pwsh_over_powershell() {
- assert_eq!(
- shell_program_candidates_for_windows(),
- &["pwsh", "powershell", "cmd"]
- );
- }
-
- #[test]
- fn windows_shell_program_items_hide_powershell_when_pwsh_exists() {
- let items = shell_program_items_for_candidates(
- shell_program_candidates_for_windows(),
- fallback_shell_program(),
- |name| matches!(name, "pwsh" | "powershell" | "cmd"),
- );
-
- assert_eq!(items, vec!["pwsh".to_string(), "cmd".to_string()]);
- }
-
- #[test]
- fn windows_shell_program_items_use_powershell_when_pwsh_is_missing() {
- let items = shell_program_items_for_candidates(
- shell_program_candidates_for_windows(),
- fallback_shell_program(),
- |name| matches!(name, "powershell" | "cmd"),
- );
-
- assert_eq!(items, vec!["powershell".to_string(), "cmd".to_string()]);
- }
-
#[test]
fn split_pathext_ignores_empty_segments() {
assert_eq!(
diff --git a/crates/gpui_term/src/view/input.rs b/crates/gpui_term/src/view/input.rs
new file mode 100644
index 0000000..02fe9cf
--- /dev/null
+++ b/crates/gpui_term/src/view/input.rs
@@ -0,0 +1,54 @@
+use gpui::{Context, KeyDownEvent, ReadGlobal, Window};
+
+use super::TerminalView;
+use crate::{
+ settings::{CursorShape, TerminalSettings},
+ terminal::{Event, UserInput},
+};
+
+impl TerminalView {
+ pub(super) fn forward_keystroke_to_terminal(
+ &mut self,
+ event: &KeyDownEvent,
+ cx: &mut Context,
+ ) {
+ let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
+ let handled = term.try_keystroke(
+ &event.keystroke,
+ TerminalSettings::global(cx).option_as_meta,
+ );
+ (handled, term.vi_mode_enabled())
+ });
+
+ if handled {
+ cx.stop_propagation();
+ // In terminal vi-mode, keystrokes are usually for scrollback/navigation, so don't
+ // force the view back to the bottom.
+ if !vi_mode_enabled {
+ self.snap_to_bottom_on_input(cx);
+ }
+ cx.emit(Event::UserInput(UserInput::Keystroke(
+ event.keystroke.clone(),
+ )));
+ }
+ }
+
+ pub(super) fn focus_in(&mut self, window: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |terminal, _| {
+ terminal.set_cursor_shape(self.cursor_shape);
+ terminal.focus_in();
+ });
+ self.blink_cursors(self.blink.epoch, cx);
+ window.invalidate_character_coordinates();
+ cx.notify();
+ }
+
+ pub(super) fn focus_out(&mut self, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |terminal, _| {
+ terminal.focus_out();
+ terminal.set_cursor_shape(CursorShape::Hollow);
+ });
+ self.suggestions.close();
+ cx.notify();
+ }
+}
diff --git a/crates/gpui_term/src/view/line_number.rs b/crates/gpui_term/src/view/line_number.rs
index f7fc277..1274175 100644
--- a/crates/gpui_term/src/view/line_number.rs
+++ b/crates/gpui_term/src/view/line_number.rs
@@ -1,4 +1,4 @@
-use std::fmt::Write as _;
+use std::fmt::Write;
use gpui::{App, Bounds, Pixels, TextAlign, TextRun, TextStyle, Window, point, px};
use gpui_component::ActiveTheme;
@@ -232,7 +232,9 @@ pub(crate) fn paint_line_numbers(
#[cfg(test)]
mod tests {
- use super::*;
+ use gpui::{Pixels, px};
+
+ use super::{compute_line_number_layout, format_line_number};
#[test]
fn reserves_minimum_gutter_without_line_numbers() {
diff --git a/crates/gpui_term/src/view/mod.rs b/crates/gpui_term/src/view/mod.rs
index bb5b1b6..6a142f9 100644
--- a/crates/gpui_term/src/view/mod.rs
+++ b/crates/gpui_term/src/view/mod.rs
@@ -1,55 +1,46 @@
-use std::{
- cmp,
- collections::VecDeque,
- ops::{Range, RangeInclusive},
- sync::Arc,
- time::Duration,
-};
+use std::{collections::VecDeque, ops::Range, sync::Arc, time::Duration};
use gpui::{
Action, AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyContext, KeyDownEvent, Keystroke, MouseButton,
- MouseDownEvent, ParentElement, Pixels, PromptLevel, ReadGlobal, Render, ScrollWheelEvent,
- Styled, Subscription, Window, div, px,
+ ParentElement, Pixels, PromptLevel, ReadGlobal, Styled, Subscription, Window, div, px,
};
use gpui_common::TermuaIcon;
use gpui_component::{
ActiveTheme, Icon, IconName, WindowExt,
- menu::{ContextMenu, PopupMenu, PopupMenuItem},
+ menu::{PopupMenu, PopupMenuItem},
notification::Notification,
};
use record::{RecordingMenuEntry, recording_context_menu_entry, recording_indicator_label};
use schemars::JsonSchema;
-use scrollbar::{SCROLLBAR_WIDTH, ScrollState, ScrollbarPreview, buffer_index_for_line_coord};
+use scrolling::{SCROLLBAR_WIDTH, ScrollState, ScrollbarPreview};
use serde::Deserialize;
use smol::Timer;
use crate::{
- Copy, DecreaseFontSize, GridPoint, HoveredWord, IncreaseFontSize, ResetFontSize,
- TerminalContent, TerminalMode,
- element::{ScrollbarPreviewTextElement, TerminalElement},
- point_to_viewport,
+ Copy, DecreaseFontSize, HoveredWord, IncreaseFontSize, ResetFontSize, TerminalContent,
+ TerminalMode,
+ element::ScrollbarPreviewTextElement,
record::render_recording_indicator_label,
settings::{CursorShape, TerminalBlink, TerminalSettings},
- snippet::{SnippetJump, SnippetJumpDir, SnippetSession, parse_snippet_suffix},
+ snippet::{SnippetJump, SnippetJumpDir, SnippetSession},
suggestions::{
- SelectionMove, SuggestionEngine, SuggestionHistoryConfig, SuggestionItem,
- SuggestionStaticConfig, compute_insert_suffix_for_line, extract_cursor_line_prefix,
- extract_cursor_line_suffix, line_is_suggestion_prefix, move_selection_opt,
+ SuggestionEngine, SuggestionHistoryConfig, SuggestionItem, SuggestionStaticConfig,
},
terminal::{
- Clear, Event, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp,
- ScrollToBottom, ScrollToTop, Search, SearchClose, SearchNext, SearchPaste, SearchPrevious,
- SelectAll, ShowCharacterPalette, StartCastRecording, StopCastRecording, Terminal,
- TerminalBounds, ToggleCastRecording, ToggleViMode, UserInput,
+ Clear, Event, Paste, SelectAll, ShowCharacterPalette, StartCastRecording,
+ StopCastRecording, Terminal, TerminalBounds, ToggleCastRecording, UserInput,
},
view::search::{SearchState, render_search},
};
+mod input;
pub(crate) mod line_number;
pub(crate) mod record;
-pub(crate) mod scrollbar;
+mod render;
+pub(crate) mod scrolling;
pub(crate) mod search;
+mod suggestions;
fn format_scrollbar_preview_line_number(one_based: usize, digits: usize) -> String {
let digits = digits.max(1);
@@ -642,949 +633,59 @@ impl TerminalView {
self.terminal.update(cx, |term, _| {
term.stop_cast_recording();
});
- self.show_toast(PromptLevel::Info, "Recording stopped", None, window, cx);
- }
-
- fn toggle_cast_recording(
- &mut self,
- _: &ToggleCastRecording,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let recording_active = self.cast_recording_active(cx);
- if recording_active {
- self.stop_cast_recording(&StopCastRecording, window, cx);
- } else {
- self.start_cast_recording(&StartCastRecording, window, cx);
- }
- }
-
- fn open_search(&mut self, _: &Search, window: &mut Window, cx: &mut Context) {
- self.search.search_open = true;
- self.search.search_panel_dragging = false;
- self.search.search_panel_drag_start_mouse = None;
- self.search.search_panel_drag_start_pos = None;
- self.search.search_expected_commit = None;
- self.search.search.end();
-
- // Default position: near the top, centered within the current window.
- let viewport = window.viewport_size();
- let panel_w = px(520.0)
- .min((viewport.width - px(24.0)).max(Pixels::ZERO))
- .max(px(320.0).min(viewport.width.max(Pixels::ZERO)));
- let keep = px(32.0);
- if !self.search.search_panel_pos_initialized {
- let x = (viewport.width - panel_w).max(Pixels::ZERO) / 2.0;
- self.search.search_panel_pos = gpui::point(x, px(72.0));
- self.search.search_panel_pos_initialized = true;
- } else {
- // If the window size changed since the last open, keep the panel reachable.
- let mut pos = self.search.search_panel_pos;
- pos.x = pos
- .x
- .clamp((Pixels::ZERO - panel_w) + keep, viewport.width - keep);
- pos.y = pos.y.clamp(px(0.0), viewport.height - keep);
- self.search.search_panel_pos = pos;
- }
-
- window.focus(&self.focus_handle, cx);
- cx.notify();
- }
-
- fn close_search(&mut self, _: &SearchClose, window: &mut Window, cx: &mut Context) {
- self.search.search_open = false;
- self.clear_scrollbar_preview(cx);
- self.search.search_epoch = self.search.search_epoch.wrapping_add(1);
- self.search.search.clear();
- self.search.search_ime_state = None;
- self.search.search_expected_commit = None;
- self.search.search_panel_dragging = false;
- self.search.search_panel_drag_start_mouse = None;
- self.search.search_panel_drag_start_pos = None;
-
- window.focus(&self.focus_handle, cx);
-
- self.terminal.update(cx, |term, _| {
- term.set_search_query(None);
- term.select_matches(&[]);
- });
- cx.notify();
- }
-
- fn search_next(&mut self, _: &SearchNext, _window: &mut Window, cx: &mut Context) {
- self.jump_search(true, cx);
- }
-
- fn search_previous(
- &mut self,
- _: &SearchPrevious,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- self.jump_search(false, cx);
- }
-
- fn jump_search(&mut self, forward: bool, cx: &mut Context) {
- self.terminal.update(cx, |term, _| {
- let matches_len = term.matches().len();
- if matches_len == 0 {
- return;
- }
-
- let cur = term.active_match_index().unwrap_or(0) % matches_len;
- let next = if forward {
- (cur + 1) % matches_len
- } else {
- cur.checked_sub(1).unwrap_or(matches_len - 1)
- };
- term.activate_match(next);
- term.jump_to_match(next);
- });
- cx.notify();
- }
-
- fn search_paste(&mut self, _: &SearchPaste, _: &mut Window, cx: &mut Context) {
- if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
- self.search.search.insert(&text);
- self.schedule_search_update(cx);
- }
- }
-
- pub(crate) fn commit_search_text(&mut self, text: &str, cx: &mut Context) {
- if text.is_empty() {
- return;
- }
- if self.search.search_expected_commit.as_deref() == Some(text) {
- self.search.search_expected_commit = None;
- return;
- }
- self.search.search_expected_commit = None;
- self.search.search_ime_state = None;
- self.search.search.insert(text);
- self.schedule_search_update(cx);
- }
-
- pub(crate) fn set_search_marked_text(
- &mut self,
- text: String,
- range: Option>,
- cx: &mut Context,
- ) {
- self.search.search_ime_state = Some(ImeState {
- marked_text: text,
- marked_range_utf16: range,
- });
- cx.notify();
- }
-
- pub(crate) fn clear_search_marked_text(&mut self, cx: &mut Context) {
- self.search.search_ime_state = None;
- cx.notify();
- }
-
- pub(crate) fn is_search_open(&self) -> bool {
- self.search.search_open
- }
-
- pub(crate) fn suggestions_snapshot(&self) -> Option<(Vec, Option)> {
- self.suggestions.open.then(|| {
- let highlighted = self
- .suggestions
- .hovered
- .or(self.suggestions.selected)
- .and_then(|highlighted| {
- let last = self.suggestions.items.len().saturating_sub(1);
- (!self.suggestions.items.is_empty()).then_some(highlighted.min(last))
- });
- (self.suggestions.items.clone(), highlighted)
- })
- }
-
- pub(crate) fn close_suggestions(&mut self, cx: &mut Context) {
- if !self.suggestions.open {
- return;
- }
- self.suggestions.close();
- cx.notify();
- }
-
- pub(crate) fn snippet_snapshot_for_content(
- &self,
- content: &TerminalContent,
- cursor_line_id: Option,
- cx: &App,
- ) -> Option {
- let snippet = self.snippet.clone()?;
- if !self.suggestions_eligible_for_content(content, cx) {
- return None;
- }
-
- if let Some(expected) = snippet.cursor_line_id
- && cursor_line_id != Some(expected)
- {
- return None;
- }
-
- Some(snippet)
- }
-
- pub(crate) fn set_suggestions_hovered(
- &mut self,
- hovered: Option,
- cx: &mut Context,
- ) {
- if !self.suggestions.open {
- if self.suggestions.hovered.take().is_some() {
- cx.notify();
- }
- return;
- }
-
- let hovered = hovered.and_then(|idx| {
- let last = self.suggestions.items.len().saturating_sub(1);
- (!self.suggestions.items.is_empty()).then_some(idx.min(last))
- });
-
- if self.suggestions.hovered != hovered {
- self.suggestions.hovered = hovered;
- cx.notify();
- }
- }
-
- pub(crate) fn search_marked_text_range(&self) -> Option> {
- self.search
- .search_ime_state
- .as_ref()
- .and_then(|state| state.marked_range_utf16.clone())
- }
-
- pub(crate) fn search_panel_pos(&self) -> gpui::Point {
- self.search.search_panel_pos
- }
-
- fn suggestions_eligible_for_content(&self, content: &TerminalContent, cx: &App) -> bool {
- TerminalSettings::global(cx).suggestions_enabled
- && content.display_offset == 0
- && !content.mode.contains(TerminalMode::ALT_SCREEN)
- && content.selection.is_none()
- && self.scroll.block_below_cursor.is_none()
- }
-
- fn schedule_suggestions_update(&mut self, cx: &mut Context) {
- let epoch = self.suggestions.epoch.wrapping_add(1);
- self.suggestions.epoch = epoch;
- cx.spawn(async move |this, cx| {
- Timer::after(Duration::from_millis(200)).await;
- let _ = this.update(cx, |this, cx| {
- if this.suggestions.epoch != epoch {
- return;
- }
-
- let Some(prompt) = this.prompt_context(cx) else {
- return;
- };
- if !this.suggestions_eligible_for_content(&prompt.content, cx) {
- this.suggestions.prompt_prefix = None;
- this.suggestions.close();
- cx.notify();
- return;
- }
-
- let Some(prompt_prefix) = this.suggestions.prompt_prefix.clone() else {
- this.suggestions.close();
- cx.notify();
- return;
- };
-
- let line_prefix = extract_cursor_line_prefix(&prompt.content);
- let input_prefix = line_prefix.strip_prefix(&prompt_prefix).unwrap_or("");
-
- this.suggestions.engine.max_items =
- TerminalSettings::global(cx).suggestions_max_items;
-
- if let Some(cfg) = cx.try_global::()
- && cfg.epoch != this.suggestions.static_epoch_seen
- {
- this.suggestions.static_epoch_seen = cfg.epoch;
- this.suggestions
- .engine
- .set_static_provider(cfg.provider.clone());
- }
-
- let items = this.suggestions.engine.suggest(input_prefix);
- this.suggestions.open_with_items(items);
- cx.notify();
- });
- })
- .detach();
- }
-
- fn accept_selected_suggestion(
- &mut self,
- content: &TerminalContent,
- cursor_line_id: Option,
- cx: &mut Context,
- ) -> bool {
- if !self.suggestions.open {
- return false;
- }
-
- let Some(selected) = self.suggestions.selected else {
- return false;
- };
- self.accept_suggestion_at_index(selected, content, cursor_line_id, cx)
- }
-
- pub(crate) fn accept_suggestion_at_index(
- &mut self,
- index: usize,
- content: &TerminalContent,
- cursor_line_id: Option,
- cx: &mut Context,
- ) -> bool {
- if !self.suggestions.open {
- return false;
- }
-
- let Some(item) = self.suggestions.items.get(index).cloned() else {
- return false;
- };
-
- let line_prefix = extract_cursor_line_prefix(content);
- let Some((input_prefix, suffix_template)) = compute_insert_suffix_for_line(
- &line_prefix,
- self.suggestions.prompt_prefix.as_deref(),
- &item.full_text,
- ) else {
- return false;
- };
-
- let line_suffix = extract_cursor_line_suffix(content);
- if !line_suffix.trim().is_empty() {
- let combined_line = format!("{input_prefix}{line_suffix}");
- if line_is_suggestion_prefix(&combined_line, &item.full_text) {
- return false;
- }
- }
-
- let mut suffix_rendered = suffix_template.clone();
- let mut snippet_session: Option = None;
- let mut initial_move_left = 0usize;
-
- if let Some(snippet) = parse_snippet_suffix(&suffix_template) {
- suffix_rendered = snippet.rendered;
-
- // `$0` is a cursor position, not an editable placeholder. Avoid entering snippet mode
- // if there are no non-zero tabstops.
- if snippet.tabstops.iter().any(|t| t.index != 0) {
- let mut session = SnippetSession::new(suffix_rendered.clone(), snippet.tabstops);
- session.cursor_line_id = cursor_line_id;
- session.start_point = content.cursor.point;
- session.active = 0;
-
- let target_end = session
- .tabstops
- .first()
- .map(|t| t.range_chars.end)
- .unwrap_or(session.inserted_len_chars);
-
- initial_move_left = session.inserted_len_chars.saturating_sub(target_end);
- session.cursor_offset_chars = target_end;
- session.selected = true;
- snippet_session = Some(session);
- }
- }
-
- self.snap_to_bottom_on_input(cx);
- let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
- let left = Keystroke::parse("left").unwrap();
-
- let suffix = suffix_rendered.into_bytes();
- self.terminal.update(cx, move |term, _| {
- term.input(suffix);
- for _ in 0..initial_move_left {
- term.try_keystroke(&left, alt_is_meta);
- }
- });
- self.snippet = snippet_session;
- self.suggestions.close();
- true
- }
-
- fn schedule_search_update(&mut self, cx: &mut Context) {
- let epoch = self.search.search_epoch.wrapping_add(1);
- self.search.search_epoch = epoch;
- cx.spawn(async move |this, cx| {
- Timer::after(Duration::from_millis(150)).await;
- let _ = this.update(cx, |this, cx| {
- if !this.search.search_open || this.search.search_epoch != epoch {
- return;
- }
-
- // Only treat all-whitespace as empty; otherwise keep the query exactly as typed.
- let q = this.search.search.text().to_string();
- this.terminal.update(cx, |term, _| {
- if q.chars().all(|c| c.is_whitespace()) {
- term.set_search_query(None);
- } else {
- term.set_search_query(Some(q));
- if !term.matches().is_empty() {
- term.activate_match(0);
- }
- }
- });
- cx.notify();
- });
- })
- .detach();
- }
-
- pub(crate) fn search_cursor_utf16(&self) -> usize {
- self.search.search.cursor_utf16()
- }
-
- fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |term, _| term.select_all());
- cx.notify();
- }
-
- fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |term, _| term.clear());
- cx.notify();
- }
-
- fn reset_font_size(&mut self, _: &ResetFontSize, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |_, cx| {
- cx.global_mut::().font_size = px(15.);
- });
- cx.notify();
- }
-
- fn increase_font_size(&mut self, _: &IncreaseFontSize, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |_, cx| {
- let font_size = cx.global::().font_size;
- if font_size >= px(100.) {
- return;
- }
- cx.global_mut::().font_size += px(1.);
- });
- cx.notify();
- }
-
- fn decrease_font_size(&mut self, _: &DecreaseFontSize, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |_, cx| {
- let font_size = cx.global::().font_size;
- if font_size <= px(5.) {
- return;
- }
- cx.global_mut::().font_size -= px(1.);
- });
- cx.notify();
- }
-
- fn max_scroll_top(&self, cx: &App) -> Pixels {
- let terminal = self.terminal.read(cx);
-
- let Some(block) = self.scroll.block_below_cursor.as_ref() else {
- return Pixels::ZERO;
- };
-
- let content = terminal.last_content();
- let line_height = content.terminal_bounds.line_height;
- let viewport_lines = terminal.viewport_lines();
- let cursor =
- point_to_viewport(content.display_offset, content.cursor.point).unwrap_or_default();
- let max_scroll_top_in_lines =
- (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
-
- max_scroll_top_in_lines as f32 * line_height
- }
-
- /// Zed-like behavior: if the user is looking at history (terminal scrollback) or has scrolled
- /// away from the live block content, any input that is forwarded to the PTY should snap the
- /// view back to the bottom.
- fn snap_to_bottom_on_input(&mut self, cx: &mut Context) {
- let TerminalScrollState {
- display_offset,
- line_height,
- ..
- } = self.terminal_scroll_state(cx);
-
- // History scrolling: ensure we immediately return to the live viewport on any PTY input.
- // (Some input paths, like `try_keystroke`, don't go through backend `input()`.)
- if display_offset != 0 {
- self.terminal.update(cx, |term, _| term.scroll_to_bottom());
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = true;
- cx.notify();
- return;
- }
-
- // Block-below-cursor extra scroll space (e.g. prompt block). Only applies in live view.
- if self.scroll.block_below_cursor.is_some() {
- let max = self.max_scroll_top(cx);
- let at_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
-
- if !at_bottom {
- self.scroll.scroll_top = max;
- }
- }
-
- // Typing implies "follow output" unless the user scrolls away again.
- self.scroll.stick_to_bottom = true;
- cx.notify();
- }
-
- pub(crate) fn set_mouse_left_down_in_terminal(&mut self, down: bool) {
- self.scroll.mouse_left_down_in_terminal = down;
- }
-
- pub(crate) fn mouse_left_down_in_terminal(&self) -> bool {
- self.scroll.mouse_left_down_in_terminal
- }
-
- pub(crate) fn scroll_wheel(
- &mut self,
- event: &ScrollWheelEvent,
- window: &mut Window,
- cx: &mut Context,
- ) {
- // Scroll-wheel usage should reveal the overlay scrollbar briefly, even if the pointer
- // isn't within the scrollbar lane.
- //
- // Note: This is kept view-local so we can avoid allocating dedicated layout space.
- // The element decides whether to paint the scrollbar based on this flag.
- // (Timer-based auto-hide happens in `reveal_scrollbar_for_scroll`.)
- //
- // We don't require focus checks here; callers already gate on focus.
- self.reveal_scrollbar_for_scroll(window, cx);
-
- let TerminalScrollState {
- is_remote_mirror,
- display_offset,
- line_height,
- } = self.terminal_scroll_state(cx);
-
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_wheel(event));
- return;
- }
-
- if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
- let y_delta = event.delta.pixel_delta(line_height).y;
- if y_delta < Pixels::ZERO || self.scroll.scroll_top > Pixels::ZERO {
- let max = self.max_scroll_top(cx);
- self.scroll.scroll_top = cmp::max(
- Pixels::ZERO,
- cmp::min(self.scroll.scroll_top - y_delta, max),
- );
- self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
- cx.notify();
- return;
- }
- }
- // Scrolling the terminal history should never keep a block-scroll offset.
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
- self.terminal.update(cx, |term, _| term.scroll_wheel(event));
- }
-
- pub(crate) fn scrollbar_dragging(&self) -> bool {
- self.scroll.scrollbar_dragging
- }
-
- pub(crate) fn scrollbar_hovered(&self) -> bool {
- self.scroll.scrollbar_hovered
- }
-
- pub(crate) fn scrollbar_revealed(&self) -> bool {
- self.scroll.scrollbar_revealed
- }
-
- pub(crate) fn set_scrollbar_hovered(&mut self, hovered: bool, cx: &mut Context) {
- if self.scroll.scrollbar_hovered != hovered {
- self.scroll.scrollbar_hovered = hovered;
- cx.notify();
- }
- }
-
- fn reveal_scrollbar_for_scroll(&mut self, _window: &mut Window, cx: &mut Context) {
- if !TerminalSettings::global(cx).show_scrollbar {
- return;
- }
- self.scroll.scrollbar_revealed = true;
- self.scroll.scrollbar_reveal_epoch = self.scroll.scrollbar_reveal_epoch.wrapping_add(1);
- let epoch = self.scroll.scrollbar_reveal_epoch;
- cx.notify();
-
- // Auto-hide after a short delay. If the user is hovering/dragging at that time, the
- // scrollbar stays visible due to those signals (not this temporary reveal flag).
- cx.spawn(async move |this, cx| {
- Timer::after(Duration::from_millis(900)).await;
- let _ = this.update(cx, |this, cx| {
- if this.scroll.scrollbar_reveal_epoch != epoch {
- return;
- }
- if this.scroll.scrollbar_revealed {
- this.scroll.scrollbar_revealed = false;
- cx.notify();
- }
- });
- })
- .detach();
- }
-
- pub(crate) fn scroll_top(&self) -> Pixels {
- self.scroll.scroll_top
- }
-
- pub(crate) fn scrollbar_virtual_offset(&self) -> Option {
- self.scroll.scrollbar_virtual_offset
- }
-
- pub(crate) fn scrollbar_drag_origin(&self) -> Option<(Pixels, usize)> {
- Some((
- self.scroll.scrollbar_drag_start_y?,
- self.scroll.scrollbar_drag_start_offset?,
- ))
- }
-
- pub(crate) fn set_scrollbar_drag_origin(&mut self, mouse_y: Pixels, offset: usize) {
- self.scroll.scrollbar_drag_start_y = Some(mouse_y);
- self.scroll.scrollbar_drag_start_offset = Some(offset);
- }
-
- pub(crate) fn begin_scrollbar_drag(&mut self, mouse_y: Pixels, cx: &mut Context) {
- self.scroll.scrollbar_dragging = true;
- let current = {
- let terminal = self.terminal.read(cx);
- terminal.last_content().display_offset
- };
- self.scroll.scrollbar_virtual_offset = Some(current);
- self.scroll.scrollbar_last_target_offset = Some(current);
- self.set_scrollbar_drag_origin(mouse_y, current);
- }
-
- pub(crate) fn end_scrollbar_drag(&mut self) {
- self.scroll.scrollbar_dragging = false;
- self.scroll.scrollbar_last_target_offset = None;
- self.scroll.scrollbar_virtual_offset = None;
- self.scroll.scrollbar_drag_start_y = None;
- self.scroll.scrollbar_drag_start_offset = None;
- }
-
- pub(crate) fn scrollbar_preview(&self) -> Option<&ScrollbarPreview> {
- self.scroll.scrollbar_preview.as_ref()
- }
-
- pub(crate) fn clear_scrollbar_preview(&mut self, cx: &mut Context) {
- if self.scroll.scrollbar_preview.take().is_some() {
- cx.notify();
- }
- }
-
- pub(crate) fn set_scrollbar_preview_for_match(
- &mut self,
- match_index: usize,
- anchor: gpui::Point,
- cx: &mut Context,
- ) {
- let Some((start, cols, rows, cells, match_range)) = (|| {
- let terminal = self.terminal.read(cx);
- let matches = terminal.matches();
- if match_index >= matches.len() {
- return None;
- }
-
- let total_lines = terminal.total_lines();
- let viewport_lines = terminal.viewport_lines();
- let match_range = matches[match_index].clone();
-
- let start_line_coord = match_range.start().line;
- let end_line_coord = match_range.end().line;
- let start_line_from_top =
- buffer_index_for_line_coord(total_lines, viewport_lines, start_line_coord);
- let end_line_from_top =
- buffer_index_for_line_coord(total_lines, viewport_lines, end_line_coord);
-
- let context_above = 3usize;
- let total = 7usize;
- let start = start_line_from_top.saturating_sub(context_above);
-
- let (cols, rows, cells) = terminal.preview_cells_from_top(start, total);
- if rows == 0 || cells.is_empty() {
- return None;
- }
-
- // Convert the match range into preview-local coordinates (preview starts at line 0).
- let local_start_line = start_line_from_top.saturating_sub(start);
- let local_end_line = end_line_from_top.saturating_sub(start);
- let local_range = RangeInclusive::new(
- GridPoint::new(local_start_line as i32, match_range.start().column),
- GridPoint::new(local_end_line as i32, match_range.end().column),
- );
-
- Some((start, cols, rows, cells, local_range))
- })() else {
- self.clear_scrollbar_preview(cx);
- return;
- };
-
- // Avoid re-fetching preview text while the pointer moves within the same marker.
- if let Some(prev) = self.scroll.scrollbar_preview.as_mut()
- && prev.match_index == match_index
- && prev.start_line_from_top == start
- {
- prev.anchor = anchor;
- cx.notify();
- return;
- }
-
- self.scroll.scrollbar_preview = Some(ScrollbarPreview {
- match_index,
- anchor,
- start_line_from_top: start,
- cols,
- rows,
- cells,
- match_range,
- });
- cx.notify();
- }
-
- pub(crate) fn apply_scrollbar_target_offset(
- &mut self,
- target_offset: usize,
- cx: &mut Context,
- ) {
- if self.scroll.scrollbar_last_target_offset == Some(target_offset) {
- return;
- }
- self.scroll.scrollbar_last_target_offset = Some(target_offset);
- let current = self.scroll.scrollbar_virtual_offset.unwrap_or_else(|| {
- let terminal = self.terminal.read(cx);
- terminal.last_content().display_offset
- });
- self.scroll_to_display_offset_from_current(current, target_offset, cx);
- self.scroll.scrollbar_virtual_offset = Some(target_offset);
- }
-
- fn scroll_to_display_offset_from_current(
- &mut self,
- current_offset: usize,
- target_offset: usize,
- cx: &mut Context,
- ) {
- let (is_remote_mirror, max_offset) = {
- let terminal = self.terminal.read(cx);
- let total_lines = terminal.total_lines();
- let viewport_lines = terminal.viewport_lines();
- (
- terminal.is_remote_mirror(),
- total_lines.saturating_sub(viewport_lines),
- )
- };
-
- let current_offset = current_offset.min(max_offset);
- let target_offset = target_offset.min(max_offset);
- if target_offset == current_offset {
- if !is_remote_mirror {
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = target_offset == 0;
- }
- return;
- }
-
- if !is_remote_mirror {
- // Scrolling the terminal history should never keep the extra "block below cursor"
- // scroll.
- self.scroll.scroll_top = Pixels::ZERO;
- }
-
- self.terminal.update(cx, |term, _| {
- if target_offset == 0 {
- term.scroll_to_bottom();
- } else if target_offset == max_offset {
- term.scroll_to_top();
- } else if target_offset > current_offset {
- term.scroll_up_by(target_offset - current_offset);
- } else {
- term.scroll_down_by(current_offset - target_offset);
- }
- });
-
- if !is_remote_mirror {
- self.scroll.stick_to_bottom = target_offset == 0;
- cx.notify();
- }
- }
-
- // `scroll_to_display_offset_from_current` is the only implementation we need right now.
-
- fn scroll_line_up(&mut self, _: &ScrollLineUp, window: &mut Window, cx: &mut Context) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let TerminalScrollState {
- is_remote_mirror,
- display_offset,
- line_height,
- } = self.terminal_scroll_state(cx);
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_line_up());
- return;
- }
- if self.scroll.block_below_cursor.is_some()
- && display_offset == 0
- && self.scroll.scroll_top > Pixels::ZERO
- {
- self.scroll.scroll_top = cmp::max(self.scroll.scroll_top - line_height, Pixels::ZERO);
- let max = self.max_scroll_top(cx);
- self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
- return;
- }
-
- self.terminal.update(cx, |term, _| term.scroll_line_up());
- // Terminal scrollback and block scrolling are mutually exclusive.
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
- cx.notify();
- }
-
- fn scroll_line_down(
- &mut self,
- _: &ScrollLineDown,
- window: &mut Window,
- cx: &mut Context,
- ) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let TerminalScrollState {
- is_remote_mirror,
- display_offset,
- line_height,
- } = self.terminal_scroll_state(cx);
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_line_down());
- return;
- }
- if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
- let max_scroll_top = self.max_scroll_top(cx);
- if self.scroll.scroll_top < max_scroll_top {
- self.scroll.scroll_top =
- cmp::min(self.scroll.scroll_top + line_height, max_scroll_top);
- }
- self.scroll.stick_to_bottom =
- self.scroll.scroll_top + line_height / 2.0 >= max_scroll_top;
- return;
- }
-
- self.terminal.update(cx, |term, _| term.scroll_line_down());
- // Terminal scrollback and block scrolling are mutually exclusive.
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
- cx.notify();
- }
-
- fn scroll_page_up(&mut self, _: &ScrollPageUp, window: &mut Window, cx: &mut Context) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let (is_remote_mirror, line_height, viewport_lines) = {
- let terminal = self.terminal.read(cx);
- (
- terminal.is_remote_mirror(),
- terminal.last_content().terminal_bounds.line_height(),
- terminal.viewport_lines(),
- )
- };
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_page_up());
- return;
- }
- if self.scroll.scroll_top == Pixels::ZERO {
- self.terminal.update(cx, |term, _| term.scroll_page_up());
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
- } else {
- let visible_block_lines = (self.scroll.scroll_top / line_height) as usize;
- let visible_content_lines = viewport_lines - visible_block_lines;
-
- if visible_block_lines >= viewport_lines {
- self.scroll.scroll_top =
- ((visible_block_lines - viewport_lines) as f32) * line_height;
- } else {
- self.scroll.scroll_top = px(0.);
- self.terminal
- .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
- }
- self.scroll.stick_to_bottom =
- self.scroll.scroll_top + line_height / 2.0 >= self.max_scroll_top(cx);
- }
- cx.notify();
+ self.show_toast(PromptLevel::Info, "Recording stopped", None, window, cx);
}
- fn scroll_page_down(
+ fn toggle_cast_recording(
&mut self,
- _: &ScrollPageDown,
+ _: &ToggleCastRecording,
window: &mut Window,
cx: &mut Context,
) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let TerminalScrollState {
- is_remote_mirror,
- display_offset,
- ..
- } = self.terminal_scroll_state(cx);
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_page_down());
- return;
- }
- self.terminal.update(cx, |term, _| term.scroll_page_down());
- // Scrolling the terminal history should not apply block scrolling offsets.
- // `scroll_top` is only meaningful while we're at the live view.
- if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
- self.scroll.scroll_top = self.max_scroll_top(cx);
- self.scroll.stick_to_bottom = true;
+ let recording_active = self.cast_recording_active(cx);
+ if recording_active {
+ self.stop_cast_recording(&StopCastRecording, window, cx);
} else {
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
+ self.start_cast_recording(&StartCastRecording, window, cx);
}
+ }
+
+ fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |term, _| term.select_all());
cx.notify();
}
- fn scroll_to_top(&mut self, _: &ScrollToTop, window: &mut Window, cx: &mut Context) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror;
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_to_top());
- return;
- }
- self.terminal.update(cx, |term, _| term.scroll_to_top());
- self.scroll.scroll_top = Pixels::ZERO;
- self.scroll.stick_to_bottom = false;
+ fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |term, _| term.clear());
cx.notify();
}
- fn scroll_to_bottom(
- &mut self,
- _: &ScrollToBottom,
- window: &mut Window,
- cx: &mut Context,
- ) {
- self.reveal_scrollbar_for_scroll(window, cx);
- let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror;
- if is_remote_mirror {
- self.terminal.update(cx, |term, _| term.scroll_to_bottom());
- return;
- }
- self.terminal.update(cx, |term, _| term.scroll_to_bottom());
- if self.scroll.block_below_cursor.is_some() {
- self.scroll.scroll_top = self.max_scroll_top(cx);
- } else {
- self.scroll.scroll_top = Pixels::ZERO;
- }
- self.scroll.stick_to_bottom = true;
+ fn reset_font_size(&mut self, _: &ResetFontSize, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |_, cx| {
+ cx.global_mut::().font_size = px(15.);
+ });
+ cx.notify();
+ }
+
+ fn increase_font_size(&mut self, _: &IncreaseFontSize, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |_, cx| {
+ let font_size = cx.global::().font_size;
+ if font_size >= px(100.) {
+ return;
+ }
+ cx.global_mut::().font_size += px(1.);
+ });
cx.notify();
}
- fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |term, _| term.toggle_vi_mode());
+ fn decrease_font_size(&mut self, _: &DecreaseFontSize, _: &mut Window, cx: &mut Context) {
+ self.terminal.update(cx, |_, cx| {
+ let font_size = cx.global::().font_size;
+ if font_size <= px(5.) {
+ return;
+ }
+ cx.global_mut::().font_size -= px(1.);
+ });
cx.notify();
}
@@ -1910,582 +1011,6 @@ impl TerminalView {
self.forward_keystroke_to_terminal(event, cx);
}
- fn handle_search_overlay_key_down_for_terminal_key_down(
- &mut self,
- event: &KeyDownEvent,
- window: &mut Window,
- cx: &mut Context,
- ) -> bool {
- match self.handle_search_overlay_key_down(event, window, cx) {
- SearchOverlayKeyDown::NotOpen => false,
- SearchOverlayKeyDown::Return => true,
- SearchOverlayKeyDown::StopAndReturn => {
- // Search is a overlay; don't forward keystrokes to the terminal.
- cx.stop_propagation();
- true
- }
- }
- }
-
- fn handle_snippet_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) -> bool {
- if self.snippet.is_none() {
- return false;
- }
-
- let eligible = self
- .prompt_context(cx)
- .is_some_and(|prompt| self.snippet_prompt_is_eligible(&prompt, cx));
-
- if !eligible {
- self.snippet = None;
- return false;
- }
-
- // Any newline ends the snippet session.
- if event.keystroke.key.as_str() == "enter" {
- self.snippet = None;
- return false;
- }
-
- let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| {
- !ch.is_empty()
- && !event.keystroke.is_ime_in_progress()
- && !event.keystroke.modifiers.control
- && !event.keystroke.modifiers.platform
- && !event.keystroke.modifiers.function
- && !event.keystroke.modifiers.alt
- });
- if is_plain_text
- && !matches!(event.keystroke.key.as_str(), "tab" | "escape")
- && let Some(ch) = event.keystroke.key_char.as_deref()
- && !ch.is_empty()
- {
- // Snippet placeholder "selection" is local UI state; terminal line editors do not
- // support replacing highlighted ranges. Treat character input as an explicit
- // commit so we can delete/replace the active placeholder and keep placeholder
- // highlight ranges in sync.
- self.commit_text(ch, cx);
- cx.notify();
- cx.stop_propagation();
- return true;
- }
-
- let Some(session) = self.snippet.as_mut() else {
- return false;
- };
-
- if event.keystroke.key.as_str() == "backspace" {
- if session.selected {
- let deleted_chars = session.delete_active_placeholder();
- session.selected = false;
-
- if deleted_chars > 0 {
- let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
- let backspace = Keystroke::parse("backspace").unwrap();
- self.terminal.update(cx, move |term, _| {
- for _ in 0..deleted_chars {
- term.try_keystroke(&backspace, alt_is_meta);
- }
- });
- cx.notify();
- cx.stop_propagation();
- return true;
- }
- } else {
- let deleted = session.backspace_one_in_active_placeholder();
- if deleted {
- let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
- let backspace = Keystroke::parse("backspace").unwrap();
- self.terminal.update(cx, move |term, _| {
- term.try_keystroke(&backspace, alt_is_meta);
- });
- cx.notify();
- cx.stop_propagation();
- return true;
- }
- }
- return false;
- }
-
- // Any unexpected navigation/editing key cancels snippet mode to avoid
- // desync with the remote line editor state.
- let key = event.keystroke.key.as_str();
- let cancel = event.keystroke.modifiers.control
- || event.keystroke.modifiers.platform
- || event.keystroke.modifiers.function
- || event.keystroke.modifiers.alt
- || matches!(
- key,
- "left"
- | "right"
- | "up"
- | "down"
- | "home"
- | "end"
- | "pageup"
- | "pagedown"
- | "delete"
- );
- if cancel {
- self.snippet = None;
- }
-
- false
- }
-
- fn handle_suggestions_key_down(
- &mut self,
- event: &KeyDownEvent,
- cx: &mut Context,
- ) -> bool {
- // Suggestions are intentionally conservative: remote/SSH sessions have no shell
- // integration, so we only show/accept append-only hints in shell-like contexts.
- if !TerminalSettings::global(cx).suggestions_enabled || self.snippet.is_some() {
- return false;
- }
-
- let Some(prompt) = self.prompt_context(cx) else {
- return false;
- };
- if !self.suggestions_eligible_for_content(&prompt.content, cx) {
- self.suggestions.prompt_prefix = None;
- self.suggestions.close();
- return false;
- }
-
- match event.keystroke.key.as_str() {
- "escape" if self.suggestions.open => {
- self.suggestions.close();
- cx.notify();
- cx.stop_propagation();
- true
- }
- "up" if self.suggestions.open => {
- self.suggestions.selected = move_selection_opt(
- self.suggestions.selected,
- self.suggestions.items.len(),
- SelectionMove::Up,
- );
- cx.notify();
- cx.stop_propagation();
- true
- }
- "down" if self.suggestions.open => {
- self.suggestions.selected = move_selection_opt(
- self.suggestions.selected,
- self.suggestions.items.len(),
- SelectionMove::Down,
- );
- cx.notify();
- cx.stop_propagation();
- true
- }
- "enter" => {
- if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) {
- cx.stop_propagation();
- return true;
- }
-
- if let Some(prompt_prefix) = self.suggestions.prompt_prefix.take() {
- let line_prefix = extract_cursor_line_prefix(&prompt.content);
- let input = line_prefix
- .strip_prefix(&prompt_prefix)
- .unwrap_or("")
- .trim()
- .to_string();
- if !input.is_empty() {
- self.queue_command_for_history(input, cx);
- }
- }
- self.suggestions.close();
- false
- }
- "right" => {
- if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) {
- cx.stop_propagation();
- return true;
- }
- false
- }
- "backspace" => {
- if self.suggestions.prompt_prefix.is_none() {
- self.suggestions.prompt_prefix =
- Some(extract_cursor_line_prefix(&prompt.content));
- }
- self.schedule_suggestions_update(cx);
- false
- }
- _ => {
- let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| {
- !ch.is_empty()
- && !event.keystroke.is_ime_in_progress()
- && !event.keystroke.modifiers.control
- && !event.keystroke.modifiers.platform
- && !event.keystroke.modifiers.function
- && !event.keystroke.modifiers.alt
- });
-
- if is_plain_text {
- if self.suggestions.prompt_prefix.is_none() {
- self.suggestions.prompt_prefix =
- Some(extract_cursor_line_prefix(&prompt.content));
- }
- self.schedule_suggestions_update(cx);
- } else if self.suggestions.open {
- self.suggestions.close();
- }
- false
- }
- }
- }
-
- fn forward_keystroke_to_terminal(&mut self, event: &KeyDownEvent, cx: &mut Context) {
- let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
- let handled = term.try_keystroke(
- &event.keystroke,
- TerminalSettings::global(cx).option_as_meta,
- );
- (handled, term.vi_mode_enabled())
- });
-
- if handled {
- cx.stop_propagation();
- // In terminal vi-mode, keystrokes are usually for scrollback/navigation, so don't
- // force the view back to the bottom.
- if !vi_mode_enabled {
- self.snap_to_bottom_on_input(cx);
- }
- cx.emit(Event::UserInput(UserInput::Keystroke(
- event.keystroke.clone(),
- )));
- }
- }
-
- fn focus_in(&mut self, window: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |terminal, _| {
- terminal.set_cursor_shape(self.cursor_shape);
- terminal.focus_in();
- });
- self.blink_cursors(self.blink.epoch, cx);
- window.invalidate_character_coordinates();
- cx.notify();
- }
-
- fn focus_out(&mut self, _: &mut Window, cx: &mut Context) {
- self.terminal.update(cx, |terminal, _| {
- terminal.focus_out();
- terminal.set_cursor_shape(CursorShape::Hollow);
- });
- self.suggestions.close();
- cx.notify();
- }
-}
-
-impl Render for TerminalView {
- fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let terminal_handle = self.terminal.clone();
- let terminal_view_handle = cx.entity();
-
- self.sync_scroll_for_render(cx);
- let focused = self.focus_handle.is_focused(window);
-
- let mut root = self.terminal_view_root_base(cx);
- root = self.terminal_view_root_mouse_handlers(root, cx);
- root = root.child(self.terminal_view_inner_wrapper(
- terminal_handle,
- terminal_view_handle.clone(),
- focused,
- cx,
- ));
- root = root.children(self.collect_overlay_elements(window, cx));
-
- if !self.context_menu_enabled {
- return root.into_any_element();
- }
-
- let context_menu_enabled = self.context_menu_enabled;
- let action_context = self.focus_handle.clone();
- let menu_terminal_handle = self.terminal.clone();
- let terminal_view = terminal_view_handle.clone();
- let context_menu_provider = self.context_menu_provider.clone();
-
- ContextMenu::new("terminal-view-context-menu", root)
- .menu(move |menu, window, cx| {
- Self::build_terminal_context_menu(
- context_menu_enabled,
- action_context.clone(),
- menu_terminal_handle.clone(),
- terminal_view.clone(),
- context_menu_provider.clone(),
- menu,
- window,
- cx,
- )
- })
- .into_any_element()
- }
-}
-
-impl TerminalView {
- fn terminal_view_root_base(&mut self, cx: &mut Context) -> gpui::Stateful {
- div()
- .id("terminal-view")
- .size_full()
- .relative()
- .track_focus(&self.focus_handle(cx))
- .key_context(self.dispatch_context(cx))
- .on_action(cx.listener(TerminalView::send_text))
- .on_action(cx.listener(TerminalView::send_keystroke))
- .on_action(cx.listener(TerminalView::open_search))
- .on_action(cx.listener(TerminalView::search_next))
- .on_action(cx.listener(TerminalView::search_previous))
- .on_action(cx.listener(TerminalView::close_search))
- .on_action(cx.listener(TerminalView::search_paste))
- .on_action(cx.listener(TerminalView::copy))
- .on_action(cx.listener(TerminalView::paste))
- .on_action(cx.listener(TerminalView::clear))
- .on_action(cx.listener(TerminalView::reset_font_size))
- .on_action(cx.listener(TerminalView::increase_font_size))
- .on_action(cx.listener(TerminalView::decrease_font_size))
- .on_action(cx.listener(TerminalView::scroll_line_up))
- .on_action(cx.listener(TerminalView::scroll_line_down))
- .on_action(cx.listener(TerminalView::scroll_page_up))
- .on_action(cx.listener(TerminalView::scroll_page_down))
- .on_action(cx.listener(TerminalView::scroll_to_top))
- .on_action(cx.listener(TerminalView::scroll_to_bottom))
- .on_action(cx.listener(TerminalView::toggle_vi_mode))
- .on_action(cx.listener(TerminalView::show_character_palette))
- .on_action(cx.listener(TerminalView::select_all))
- .on_action(cx.listener(TerminalView::start_cast_recording))
- .on_action(cx.listener(TerminalView::stop_cast_recording))
- .on_action(cx.listener(TerminalView::toggle_cast_recording))
- .on_key_down(cx.listener(Self::key_down))
- }
-
- fn terminal_view_root_mouse_handlers(
- &mut self,
- root: gpui::Stateful,
- cx: &mut Context,
- ) -> gpui::Stateful {
- let root = root.on_mouse_down(
- MouseButton::Left,
- cx.listener(|this, event: &MouseDownEvent, window, cx| {
- // Treat the left gutter (line numbers/padding) as UI chrome: allow selecting
- // command blocks there rather than starting a terminal selection.
- let content_bounds = {
- let terminal = this.terminal.read(cx);
- terminal.last_content().terminal_bounds.bounds
- };
- if event.position.x < content_bounds.origin.x {
- this.close_suggestions(cx);
- this.select_command_block_at_y(
- event.position.y,
- event.modifiers.shift,
- window,
- cx,
- );
- cx.stop_propagation();
- }
- }),
- );
-
- if !self.context_menu_enabled {
- return root;
- }
-
- root.on_mouse_down(
- MouseButton::Right,
- cx.listener(|this, event: &MouseDownEvent, window, cx| {
- this.close_suggestions(cx);
-
- // We treat the left gutter (outside the terminal content bounds) as "UI
- // chrome", not part of the terminal application. Allow the
- // context menu there even when the terminal is in mouse
- // mode (e.g. vim/tmux).
- let (content_bounds, mouse_mode_enabled, has_selection) = {
- let terminal = this.terminal.read(cx);
- (
- terminal.last_content().terminal_bounds.bounds,
- terminal.mouse_mode(event.modifiers.shift),
- terminal.last_content().selection.is_some(),
- )
- };
- let clicked_in_gutter = event.position.x < content_bounds.origin.x;
- if clicked_in_gutter {
- // Pre-select the block (if any) so context-menu actions apply.
- this.select_command_block_at_y(
- event.position.y,
- event.modifiers.shift,
- window,
- cx,
- );
- return;
- }
-
- // When the terminal is in mouse mode (e.g. vim/tmux), don't open the context
- // menu; let the application handle right clicks.
- if mouse_mode_enabled {
- cx.stop_propagation();
- return;
- }
-
- if !has_selection {
- this.terminal.update(cx, |terminal, _| {
- terminal.select_word_at_event_position(event);
- });
- window.refresh();
- }
- }),
- )
- }
-
- fn terminal_view_inner_wrapper(
- &mut self,
- terminal_handle: Entity,
- terminal_view_handle: Entity,
- focused: bool,
- cx: &mut Context,
- ) -> gpui::Stateful {
- // NOTE: Keep a wrapper div around `TerminalElement`; without it the terminal
- // element can interfere with overlay UI (context menu, etc).
- div()
- .id("terminal-view-inner")
- .size_full()
- .relative()
- .child(TerminalElement::new(
- terminal_handle,
- terminal_view_handle,
- self.focus_handle.clone(),
- focused,
- self.should_show_cursor(focused, cx),
- self.scroll.block_below_cursor.clone(),
- ))
- }
-
- fn handle_search_overlay_key_down(
- &mut self,
- event: &KeyDownEvent,
- window: &mut Window,
- cx: &mut Context,
- ) -> SearchOverlayKeyDown {
- if !self.search.search_open {
- return SearchOverlayKeyDown::NotOpen;
- }
-
- match event.keystroke.key.as_str() {
- "escape" => {
- self.close_search(&SearchClose, window, cx);
- }
- "enter" => {
- self.jump_search(!event.keystroke.modifiers.shift, cx);
- }
- "left" => self.search_overlay_move_cursor(SearchOverlayMove::Left, event, cx),
- "right" => self.search_overlay_move_cursor(SearchOverlayMove::Right, event, cx),
- "home" => self.search_overlay_move_cursor(SearchOverlayMove::Home, event, cx),
- "end" => self.search_overlay_move_cursor(SearchOverlayMove::End, event, cx),
- "backspace" => self.search_overlay_delete(SearchOverlayDelete::Prev, event, cx),
- "delete" => self.search_overlay_delete(SearchOverlayDelete::Next, event, cx),
- // Common terminal muscle memory: cmd/ctrl+a moves to start of the input.
- "a" if event.keystroke.modifiers.secondary() => {
- self.search.search.home();
- cx.notify();
- }
- // Support cmd/ctrl+v paste into the query even if keybinding dispatch is skipped.
- "v" if event.keystroke.modifiers.secondary() => {
- self.search_paste(&SearchPaste, window, cx);
- }
- // Support cmd/ctrl+g next/prev even if keybinding dispatch is skipped.
- "g" if event.keystroke.modifiers.secondary() => {
- self.jump_search(!event.keystroke.modifiers.shift, cx);
- }
- _ => {
- // Text input (including IME) is handled via the InputHandler installed by the
- // terminal element while the search is open.
- //
- // However, on some platforms plain latin text does *not* go through the IME
- // callbacks; in that case we fall back to `key_char`.
- if self.search_overlay_is_composing(event) {
- // IME is actively composing; don't insert raw keystrokes.
- return SearchOverlayKeyDown::Return;
- }
-
- if event.keystroke.modifiers.control
- || event.keystroke.modifiers.platform
- || event.keystroke.modifiers.function
- || event.keystroke.modifiers.alt
- {
- return SearchOverlayKeyDown::Return;
- }
-
- if let Some(ch) = event.keystroke.key_char.as_ref()
- && !ch.is_empty()
- {
- self.search.search_expected_commit = Some(ch.clone());
- self.search.search.insert(ch);
- self.schedule_search_update(cx);
- }
- }
- }
-
- SearchOverlayKeyDown::StopAndReturn
- }
-
- fn search_overlay_is_composing(&self, event: &KeyDownEvent) -> bool {
- self.search_overlay_has_marked_text() || event.keystroke.is_ime_in_progress()
- }
-
- fn search_overlay_has_marked_text(&self) -> bool {
- self.search
- .search_ime_state
- .as_ref()
- .is_some_and(|ime| !ime.marked_text.is_empty())
- }
-
- fn search_overlay_move_cursor(
- &mut self,
- movement: SearchOverlayMove,
- event: &KeyDownEvent,
- cx: &mut Context,
- ) {
- if self.search_overlay_is_composing(event) {
- // Let the IME handle caret movement inside an active composition.
- return;
- }
-
- match movement {
- SearchOverlayMove::Left => self.search.search.move_left(),
- SearchOverlayMove::Right => self.search.search.move_right(),
- SearchOverlayMove::Home => self.search.search.home(),
- SearchOverlayMove::End => self.search.search.end(),
- }
- cx.notify();
- }
-
- fn search_overlay_delete(
- &mut self,
- delete: SearchOverlayDelete,
- event: &KeyDownEvent,
- cx: &mut Context,
- ) {
- let should_ignore = match delete {
- SearchOverlayDelete::Prev => self.search_overlay_has_marked_text(),
- SearchOverlayDelete::Next => self.search_overlay_is_composing(event),
- };
- if should_ignore {
- // Let the platform IME drive composition edits via the InputHandler.
- return;
- }
-
- let changed = match delete {
- SearchOverlayDelete::Prev => self.search.search.delete_prev(),
- SearchOverlayDelete::Next => self.search.search.delete_next(),
- };
- if changed {
- self.schedule_search_update(cx);
- }
- cx.notify();
- }
-
fn sync_scroll_for_render(&mut self, cx: &mut Context) {
// Keep the view pinned to the last line while we're following the live view.
// This needs to happen with the latest `last_content()` (post-sync), so we do it here.
@@ -2768,19 +1293,6 @@ impl TerminalView {
}
}
-#[cfg(test)]
-fn format_clock(d: Duration) -> String {
- let secs = d.as_secs();
- let h = secs / 3600;
- let m = (secs % 3600) / 60;
- let s = secs % 60;
- if h > 0 {
- format!("{h:02}:{m:02}:{s:02}")
- } else {
- format!("{m:02}:{s:02}")
- }
-}
-
fn no_command_block_detail(blocks: &[crate::command_blocks::CommandBlock], stable: i64) -> String {
if blocks.is_empty() {
"No OSC 133 blocks detected yet. Ensure this tab is a local bash or zsh shell with TERMUA \
@@ -2912,9 +1424,20 @@ fn render_scrollbar_preview_line_numbers(
#[cfg(test)]
mod scrollbar_preview_tests {
- use std::rc::Rc;
+ use std::{borrow::Cow, ops::RangeInclusive, rc::Rc};
- use super::*;
+ use gpui::{
+ AppContext, Bounds, Context as GpuiContext, Entity, InteractiveElement, Keystroke,
+ Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
+ ScrollWheelEvent, Styled, Window, div, point, px, size,
+ };
+ use gpui_component::Root;
+
+ use super::{TerminalView, format_scrollbar_preview_line_number};
+ use crate::{
+ Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy,
+ TerminalType, settings::CursorShape, terminal::TerminalBounds,
+ };
#[test]
fn format_scrollbar_preview_line_number_right_aligns() {
@@ -2929,19 +1452,6 @@ mod scrollbar_preview_tests {
assert_eq!(format_scrollbar_preview_line_number(1, 1), "1\u{00A0}");
}
- use std::{borrow::Cow, ops::RangeInclusive};
-
- use gpui::{
- AppContext, Bounds, Context as GpuiContext, Keystroke, Modifiers, MouseDownEvent,
- MouseMoveEvent, MouseUpEvent, Pixels, ScrollWheelEvent, Window, point, px, size,
- };
- use gpui_component::Root;
-
- use crate::{
- Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy,
- TerminalType,
- };
-
pub(super) struct PreviewBackend {
content: TerminalContent,
matches: Vec>,
@@ -3207,9 +1717,12 @@ mod scrollbar_preview_tests {
mod suggestion_selection_tests {
use std::rc::Rc;
- use gpui::AppContext;
+ use gpui::{AppContext, Entity};
- use super::{scrollbar_preview_tests::PreviewBackend, *};
+ use super::{
+ SuggestionItem, SuggestionsState, TerminalView, scrollbar_preview_tests::PreviewBackend,
+ };
+ use crate::{GridPoint, TerminalContent};
#[gpui::test]
fn suggestions_open_has_no_default_selection(cx: &mut gpui::TestAppContext) {
@@ -3429,15 +1942,22 @@ mod suggestion_acceptance_shell_agnostic_tests {
use std::{
borrow::Cow,
cell::RefCell,
+ ops::RangeInclusive,
rc::Rc,
sync::{Arc, Mutex},
};
- use gpui::{AppContext, Bounds, Modifiers, MouseMoveEvent, MouseUpEvent, Pixels};
+ use gpui::{
+ AppContext, Bounds, Context, Entity, Keystroke, Modifiers, MouseDownEvent, MouseMoveEvent,
+ MouseUpEvent, Pixels, ScrollWheelEvent, Window,
+ };
use gpui_component::Root;
- use super::*;
- use crate::TerminalBackend;
+ use super::{SuggestionItem, TerminalView};
+ use crate::{
+ GridPoint, TerminalBackend, TerminalBounds, TerminalContent, TerminalSettings,
+ settings::CursorShape,
+ };
#[derive(Clone, Debug, Eq, PartialEq)]
enum BackendEvent {
@@ -3787,14 +2307,21 @@ mod prompt_context_tests {
mod snippet_placeholder_key_down_tests {
use std::{
borrow::Cow,
+ ops::RangeInclusive,
sync::{Arc, Mutex},
};
- use gpui::{AppContext, Bounds, Modifiers, MouseMoveEvent, MouseUpEvent, Pixels};
+ use gpui::{
+ AppContext, Bounds, Context, Entity, Keystroke, Modifiers, MouseDownEvent, MouseMoveEvent,
+ MouseUpEvent, Pixels, ScrollWheelEvent, Window,
+ };
use gpui_component::Root;
- use super::*;
- use crate::TerminalBackend;
+ use super::TerminalView;
+ use crate::{
+ GridPoint, TerminalBackend, TerminalBounds, TerminalContent, TerminalSettings,
+ settings::CursorShape, snippet::SnippetSession,
+ };
#[derive(Clone, Debug, Eq, PartialEq)]
enum BackendEvent {
@@ -4038,9 +2565,23 @@ mod snippet_placeholder_key_down_tests {
#[cfg(test)]
mod tests {
- use super::{format_clock, no_command_block_detail};
+ use std::time::Duration;
+
+ use super::no_command_block_detail;
use crate::command_blocks::CommandBlock;
+ fn format_clock(d: Duration) -> String {
+ let secs = d.as_secs();
+ let h = secs / 3600;
+ let m = (secs % 3600) / 60;
+ let s = secs % 60;
+ if h > 0 {
+ format!("{h:02}:{m:02}:{s:02}")
+ } else {
+ format!("{m:02}:{s:02}")
+ }
+ }
+
#[test]
fn format_clock_displays_mm_ss_or_hh_mm_ss() {
assert_eq!(format_clock(std::time::Duration::from_secs(0)), "00:00");
diff --git a/crates/gpui_term/src/view/render.rs b/crates/gpui_term/src/view/render.rs
new file mode 100644
index 0000000..958d15f
--- /dev/null
+++ b/crates/gpui_term/src/view/render.rs
@@ -0,0 +1,190 @@
+use gpui::{
+ Context, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
+ ParentElement, Render, Styled, Window, div,
+};
+use gpui_component::menu::ContextMenu;
+
+use super::TerminalView;
+use crate::{element::TerminalElement, terminal::Terminal};
+
+impl Render for TerminalView {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let terminal_handle = self.terminal.clone();
+ let terminal_view_handle = cx.entity();
+
+ self.sync_scroll_for_render(cx);
+ let focused = self.focus_handle.is_focused(window);
+
+ let mut root = self.terminal_view_root_base(cx);
+ root = self.terminal_view_root_mouse_handlers(root, cx);
+ root = root.child(self.terminal_view_inner_wrapper(
+ terminal_handle,
+ terminal_view_handle.clone(),
+ focused,
+ cx,
+ ));
+ root = root.children(self.collect_overlay_elements(window, cx));
+
+ if !self.context_menu_enabled {
+ return root.into_any_element();
+ }
+
+ let context_menu_enabled = self.context_menu_enabled;
+ let action_context = self.focus_handle.clone();
+ let menu_terminal_handle = self.terminal.clone();
+ let terminal_view = terminal_view_handle.clone();
+ let context_menu_provider = self.context_menu_provider.clone();
+
+ ContextMenu::new("terminal-view-context-menu", root)
+ .menu(move |menu, window, cx| {
+ Self::build_terminal_context_menu(
+ context_menu_enabled,
+ action_context.clone(),
+ menu_terminal_handle.clone(),
+ terminal_view.clone(),
+ context_menu_provider.clone(),
+ menu,
+ window,
+ cx,
+ )
+ })
+ .into_any_element()
+ }
+}
+
+impl TerminalView {
+ fn terminal_view_root_base(&mut self, cx: &mut Context) -> gpui::Stateful {
+ div()
+ .id("terminal-view")
+ .size_full()
+ .relative()
+ .track_focus(&self.focus_handle(cx))
+ .key_context(self.dispatch_context(cx))
+ .on_action(cx.listener(TerminalView::send_text))
+ .on_action(cx.listener(TerminalView::send_keystroke))
+ .on_action(cx.listener(TerminalView::open_search))
+ .on_action(cx.listener(TerminalView::search_next))
+ .on_action(cx.listener(TerminalView::search_previous))
+ .on_action(cx.listener(TerminalView::close_search))
+ .on_action(cx.listener(TerminalView::search_paste))
+ .on_action(cx.listener(TerminalView::copy))
+ .on_action(cx.listener(TerminalView::paste))
+ .on_action(cx.listener(TerminalView::clear))
+ .on_action(cx.listener(TerminalView::reset_font_size))
+ .on_action(cx.listener(TerminalView::increase_font_size))
+ .on_action(cx.listener(TerminalView::decrease_font_size))
+ .on_action(cx.listener(TerminalView::scroll_line_up))
+ .on_action(cx.listener(TerminalView::scroll_line_down))
+ .on_action(cx.listener(TerminalView::scroll_page_up))
+ .on_action(cx.listener(TerminalView::scroll_page_down))
+ .on_action(cx.listener(TerminalView::scroll_to_top))
+ .on_action(cx.listener(TerminalView::scroll_to_bottom))
+ .on_action(cx.listener(TerminalView::toggle_vi_mode))
+ .on_action(cx.listener(TerminalView::show_character_palette))
+ .on_action(cx.listener(TerminalView::select_all))
+ .on_action(cx.listener(TerminalView::start_cast_recording))
+ .on_action(cx.listener(TerminalView::stop_cast_recording))
+ .on_action(cx.listener(TerminalView::toggle_cast_recording))
+ .on_key_down(cx.listener(Self::key_down))
+ }
+
+ fn terminal_view_root_mouse_handlers(
+ &mut self,
+ root: gpui::Stateful,
+ cx: &mut Context,
+ ) -> gpui::Stateful {
+ let root = root.on_mouse_down(
+ MouseButton::Left,
+ cx.listener(|this, event: &MouseDownEvent, window, cx| {
+ // Treat the left gutter (line numbers/padding) as UI chrome: allow selecting
+ // command blocks there rather than starting a terminal selection.
+ let content_bounds = {
+ let terminal = this.terminal.read(cx);
+ terminal.last_content().terminal_bounds.bounds
+ };
+ if event.position.x < content_bounds.origin.x {
+ this.close_suggestions(cx);
+ this.select_command_block_at_y(
+ event.position.y,
+ event.modifiers.shift,
+ window,
+ cx,
+ );
+ cx.stop_propagation();
+ }
+ }),
+ );
+
+ if !self.context_menu_enabled {
+ return root;
+ }
+
+ root.on_mouse_down(
+ MouseButton::Right,
+ cx.listener(|this, event: &MouseDownEvent, window, cx| {
+ this.close_suggestions(cx);
+
+ // We treat the left gutter (outside the terminal content bounds) as "UI
+ // chrome", not part of the terminal application. Allow the
+ // context menu there even when the terminal is in mouse
+ // mode (e.g. vim/tmux).
+ let (content_bounds, mouse_mode_enabled, has_selection) = {
+ let terminal = this.terminal.read(cx);
+ (
+ terminal.last_content().terminal_bounds.bounds,
+ terminal.mouse_mode(event.modifiers.shift),
+ terminal.last_content().selection.is_some(),
+ )
+ };
+ let clicked_in_gutter = event.position.x < content_bounds.origin.x;
+ if clicked_in_gutter {
+ // Pre-select the block (if any) so context-menu actions apply.
+ this.select_command_block_at_y(
+ event.position.y,
+ event.modifiers.shift,
+ window,
+ cx,
+ );
+ return;
+ }
+
+ // When the terminal is in mouse mode (e.g. vim/tmux), don't open the context
+ // menu; let the application handle right clicks.
+ if mouse_mode_enabled {
+ cx.stop_propagation();
+ return;
+ }
+
+ if !has_selection {
+ this.terminal.update(cx, |terminal, _| {
+ terminal.select_word_at_event_position(event);
+ });
+ window.refresh();
+ }
+ }),
+ )
+ }
+
+ fn terminal_view_inner_wrapper(
+ &mut self,
+ terminal_handle: Entity,
+ terminal_view_handle: Entity,
+ focused: bool,
+ cx: &mut Context,
+ ) -> gpui::Stateful {
+ // NOTE: Keep a wrapper div around `TerminalElement`; without it the terminal
+ // element can interfere with overlay UI (context menu, etc).
+ div()
+ .id("terminal-view-inner")
+ .size_full()
+ .relative()
+ .child(TerminalElement::new(
+ terminal_handle,
+ terminal_view_handle,
+ self.focus_handle.clone(),
+ focused,
+ self.should_show_cursor(focused, cx),
+ self.scroll.block_below_cursor.clone(),
+ ))
+ }
+}
diff --git a/crates/gpui_term/src/view/scrollbar.rs b/crates/gpui_term/src/view/scrolling.rs
similarity index 50%
rename from crates/gpui_term/src/view/scrollbar.rs
rename to crates/gpui_term/src/view/scrolling.rs
index cb405e5..fe3e9b8 100644
--- a/crates/gpui_term/src/view/scrollbar.rs
+++ b/crates/gpui_term/src/view/scrolling.rs
@@ -1,10 +1,21 @@
-use std::{ops::RangeInclusive, rc::Rc};
+use std::{cmp, ops::RangeInclusive, rc::Rc, time::Duration};
-use gpui::{BorderStyle, Bounds, Pixels, Point, Window, fill, outline, point, px, size};
+use gpui::{
+ App, BorderStyle, Bounds, Context, Pixels, Point, ReadGlobal, ScrollWheelEvent, Window, fill,
+ outline, point, px, size,
+};
use gpui_component::ActiveTheme;
-
-use super::BlockProperties;
-use crate::GridPoint;
+use smol::Timer;
+
+use super::{BlockProperties, TerminalScrollState, TerminalView};
+use crate::{
+ GridPoint, point_to_viewport,
+ settings::TerminalSettings,
+ terminal::{
+ ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop,
+ ToggleViMode,
+ },
+};
pub(crate) const SCROLLBAR_WIDTH: Pixels = px(14.0);
pub(crate) const SCROLLBAR_PAD: Pixels = px(2.0);
@@ -495,9 +506,544 @@ pub(crate) fn paint_overlay_scrollbar(
}
}
+impl TerminalView {
+ pub(super) fn max_scroll_top(&self, cx: &App) -> Pixels {
+ let terminal = self.terminal.read(cx);
+
+ let Some(block) = self.scroll.block_below_cursor.as_ref() else {
+ return Pixels::ZERO;
+ };
+
+ let content = terminal.last_content();
+ let line_height = content.terminal_bounds.line_height;
+ let viewport_lines = terminal.viewport_lines();
+ let cursor =
+ point_to_viewport(content.display_offset, content.cursor.point).unwrap_or_default();
+ let max_scroll_top_in_lines =
+ (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
+
+ max_scroll_top_in_lines as f32 * line_height
+ }
+
+ /// Zed-like behavior: if the user is looking at history (terminal scrollback) or has scrolled
+ /// away from the live block content, any input that is forwarded to the PTY should snap the
+ /// view back to the bottom.
+ pub(super) fn snap_to_bottom_on_input(&mut self, cx: &mut Context) {
+ let TerminalScrollState {
+ display_offset,
+ line_height,
+ ..
+ } = self.terminal_scroll_state(cx);
+
+ // History scrolling: ensure we immediately return to the live viewport on any PTY input.
+ // (Some input paths, like `try_keystroke`, don't go through backend `input()`.)
+ if display_offset != 0 {
+ self.terminal.update(cx, |term, _| term.scroll_to_bottom());
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = true;
+ cx.notify();
+ return;
+ }
+
+ // Block-below-cursor extra scroll space (e.g. prompt block). Only applies in live view.
+ if self.scroll.block_below_cursor.is_some() {
+ let max = self.max_scroll_top(cx);
+ let at_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
+
+ if !at_bottom {
+ self.scroll.scroll_top = max;
+ }
+ }
+
+ // Typing implies "follow output" unless the user scrolls away again.
+ self.scroll.stick_to_bottom = true;
+ cx.notify();
+ }
+
+ pub(crate) fn set_mouse_left_down_in_terminal(&mut self, down: bool) {
+ self.scroll.mouse_left_down_in_terminal = down;
+ }
+
+ pub(crate) fn mouse_left_down_in_terminal(&self) -> bool {
+ self.scroll.mouse_left_down_in_terminal
+ }
+
+ pub(crate) fn scroll_wheel(
+ &mut self,
+ event: &ScrollWheelEvent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ // Scroll-wheel usage should reveal the overlay scrollbar briefly, even if the pointer
+ // isn't within the scrollbar lane.
+ //
+ // Note: This is kept view-local so we can avoid allocating dedicated layout space.
+ // The element decides whether to paint the scrollbar based on this flag.
+ // (Timer-based auto-hide happens in `reveal_scrollbar_for_scroll`.)
+ //
+ // We don't require focus checks here; callers already gate on focus.
+ self.reveal_scrollbar_for_scroll(window, cx);
+
+ let TerminalScrollState {
+ is_remote_mirror,
+ display_offset,
+ line_height,
+ } = self.terminal_scroll_state(cx);
+
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_wheel(event));
+ return;
+ }
+
+ if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
+ let y_delta = event.delta.pixel_delta(line_height).y;
+ if y_delta < Pixels::ZERO || self.scroll.scroll_top > Pixels::ZERO {
+ let max = self.max_scroll_top(cx);
+ self.scroll.scroll_top = cmp::max(
+ Pixels::ZERO,
+ cmp::min(self.scroll.scroll_top - y_delta, max),
+ );
+ self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
+ cx.notify();
+ return;
+ }
+ }
+ // Scrolling the terminal history should never keep a block-scroll offset.
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ self.terminal.update(cx, |term, _| term.scroll_wheel(event));
+ }
+
+ pub(crate) fn scrollbar_dragging(&self) -> bool {
+ self.scroll.scrollbar_dragging
+ }
+
+ pub(crate) fn scrollbar_hovered(&self) -> bool {
+ self.scroll.scrollbar_hovered
+ }
+
+ pub(crate) fn scrollbar_revealed(&self) -> bool {
+ self.scroll.scrollbar_revealed
+ }
+
+ pub(crate) fn set_scrollbar_hovered(&mut self, hovered: bool, cx: &mut Context) {
+ if self.scroll.scrollbar_hovered != hovered {
+ self.scroll.scrollbar_hovered = hovered;
+ cx.notify();
+ }
+ }
+
+ fn reveal_scrollbar_for_scroll(&mut self, _window: &mut Window, cx: &mut Context) {
+ if !TerminalSettings::global(cx).show_scrollbar {
+ return;
+ }
+ self.scroll.scrollbar_revealed = true;
+ self.scroll.scrollbar_reveal_epoch = self.scroll.scrollbar_reveal_epoch.wrapping_add(1);
+ let epoch = self.scroll.scrollbar_reveal_epoch;
+ cx.notify();
+
+ // Auto-hide after a short delay. If the user is hovering/dragging at that time, the
+ // scrollbar stays visible due to those signals (not this temporary reveal flag).
+ cx.spawn(async move |this, cx| {
+ Timer::after(Duration::from_millis(900)).await;
+ let _ = this.update(cx, |this, cx| {
+ if this.scroll.scrollbar_reveal_epoch != epoch {
+ return;
+ }
+ if this.scroll.scrollbar_revealed {
+ this.scroll.scrollbar_revealed = false;
+ cx.notify();
+ }
+ });
+ })
+ .detach();
+ }
+
+ pub(crate) fn scroll_top(&self) -> Pixels {
+ self.scroll.scroll_top
+ }
+
+ pub(crate) fn scrollbar_virtual_offset(&self) -> Option {
+ self.scroll.scrollbar_virtual_offset
+ }
+
+ pub(crate) fn scrollbar_drag_origin(&self) -> Option<(Pixels, usize)> {
+ Some((
+ self.scroll.scrollbar_drag_start_y?,
+ self.scroll.scrollbar_drag_start_offset?,
+ ))
+ }
+
+ pub(crate) fn set_scrollbar_drag_origin(&mut self, mouse_y: Pixels, offset: usize) {
+ self.scroll.scrollbar_drag_start_y = Some(mouse_y);
+ self.scroll.scrollbar_drag_start_offset = Some(offset);
+ }
+
+ pub(crate) fn begin_scrollbar_drag(&mut self, mouse_y: Pixels, cx: &mut Context) {
+ self.scroll.scrollbar_dragging = true;
+ let current = {
+ let terminal = self.terminal.read(cx);
+ terminal.last_content().display_offset
+ };
+ self.scroll.scrollbar_virtual_offset = Some(current);
+ self.scroll.scrollbar_last_target_offset = Some(current);
+ self.set_scrollbar_drag_origin(mouse_y, current);
+ }
+
+ pub(crate) fn end_scrollbar_drag(&mut self) {
+ self.scroll.scrollbar_dragging = false;
+ self.scroll.scrollbar_last_target_offset = None;
+ self.scroll.scrollbar_virtual_offset = None;
+ self.scroll.scrollbar_drag_start_y = None;
+ self.scroll.scrollbar_drag_start_offset = None;
+ }
+
+ pub(crate) fn scrollbar_preview(&self) -> Option<&ScrollbarPreview> {
+ self.scroll.scrollbar_preview.as_ref()
+ }
+
+ pub(crate) fn clear_scrollbar_preview(&mut self, cx: &mut Context) {
+ if self.scroll.scrollbar_preview.take().is_some() {
+ cx.notify();
+ }
+ }
+
+ pub(crate) fn set_scrollbar_preview_for_match(
+ &mut self,
+ match_index: usize,
+ anchor: gpui::Point,
+ cx: &mut Context,
+ ) {
+ let Some((start, cols, rows, cells, match_range)) = (|| {
+ let terminal = self.terminal.read(cx);
+ let matches = terminal.matches();
+ if match_index >= matches.len() {
+ return None;
+ }
+
+ let total_lines = terminal.total_lines();
+ let viewport_lines = terminal.viewport_lines();
+ let match_range = matches[match_index].clone();
+
+ let start_line_coord = match_range.start().line;
+ let end_line_coord = match_range.end().line;
+ let start_line_from_top =
+ buffer_index_for_line_coord(total_lines, viewport_lines, start_line_coord);
+ let end_line_from_top =
+ buffer_index_for_line_coord(total_lines, viewport_lines, end_line_coord);
+
+ let context_above = 3usize;
+ let total = 7usize;
+ let start = start_line_from_top.saturating_sub(context_above);
+
+ let (cols, rows, cells) = terminal.preview_cells_from_top(start, total);
+ if rows == 0 || cells.is_empty() {
+ return None;
+ }
+
+ // Convert the match range into preview-local coordinates (preview starts at line 0).
+ let local_start_line = start_line_from_top.saturating_sub(start);
+ let local_end_line = end_line_from_top.saturating_sub(start);
+ let local_range = RangeInclusive::new(
+ GridPoint::new(local_start_line as i32, match_range.start().column),
+ GridPoint::new(local_end_line as i32, match_range.end().column),
+ );
+
+ Some((start, cols, rows, cells, local_range))
+ })() else {
+ self.clear_scrollbar_preview(cx);
+ return;
+ };
+
+ // Avoid re-fetching preview text while the pointer moves within the same marker.
+ if let Some(prev) = self.scroll.scrollbar_preview.as_mut()
+ && prev.match_index == match_index
+ && prev.start_line_from_top == start
+ {
+ prev.anchor = anchor;
+ cx.notify();
+ return;
+ }
+
+ self.scroll.scrollbar_preview = Some(ScrollbarPreview {
+ match_index,
+ anchor,
+ start_line_from_top: start,
+ cols,
+ rows,
+ cells,
+ match_range,
+ });
+ cx.notify();
+ }
+
+ pub(crate) fn apply_scrollbar_target_offset(
+ &mut self,
+ target_offset: usize,
+ cx: &mut Context,
+ ) {
+ if self.scroll.scrollbar_last_target_offset == Some(target_offset) {
+ return;
+ }
+ self.scroll.scrollbar_last_target_offset = Some(target_offset);
+ let current = self.scroll.scrollbar_virtual_offset.unwrap_or_else(|| {
+ let terminal = self.terminal.read(cx);
+ terminal.last_content().display_offset
+ });
+ self.scroll_to_display_offset_from_current(current, target_offset, cx);
+ self.scroll.scrollbar_virtual_offset = Some(target_offset);
+ }
+
+ fn scroll_to_display_offset_from_current(
+ &mut self,
+ current_offset: usize,
+ target_offset: usize,
+ cx: &mut Context,
+ ) {
+ let (is_remote_mirror, max_offset) = {
+ let terminal = self.terminal.read(cx);
+ let total_lines = terminal.total_lines();
+ let viewport_lines = terminal.viewport_lines();
+ (
+ terminal.is_remote_mirror(),
+ total_lines.saturating_sub(viewport_lines),
+ )
+ };
+
+ let current_offset = current_offset.min(max_offset);
+ let target_offset = target_offset.min(max_offset);
+ if target_offset == current_offset {
+ if !is_remote_mirror {
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = target_offset == 0;
+ }
+ return;
+ }
+
+ if !is_remote_mirror {
+ // Scrolling the terminal history should never keep the extra "block below cursor"
+ // scroll.
+ self.scroll.scroll_top = Pixels::ZERO;
+ }
+
+ self.terminal.update(cx, |term, _| {
+ if target_offset == 0 {
+ term.scroll_to_bottom();
+ } else if target_offset == max_offset {
+ term.scroll_to_top();
+ } else if target_offset > current_offset {
+ term.scroll_up_by(target_offset - current_offset);
+ } else {
+ term.scroll_down_by(current_offset - target_offset);
+ }
+ });
+
+ if !is_remote_mirror {
+ self.scroll.stick_to_bottom = target_offset == 0;
+ cx.notify();
+ }
+ }
+
+ // `scroll_to_display_offset_from_current` is the only implementation we need right now.
+
+ pub(super) fn scroll_line_up(
+ &mut self,
+ _: &ScrollLineUp,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let TerminalScrollState {
+ is_remote_mirror,
+ display_offset,
+ line_height,
+ } = self.terminal_scroll_state(cx);
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_line_up());
+ return;
+ }
+ if self.scroll.block_below_cursor.is_some()
+ && display_offset == 0
+ && self.scroll.scroll_top > Pixels::ZERO
+ {
+ self.scroll.scroll_top = cmp::max(self.scroll.scroll_top - line_height, Pixels::ZERO);
+ let max = self.max_scroll_top(cx);
+ self.scroll.stick_to_bottom = self.scroll.scroll_top + line_height / 2.0 >= max;
+ return;
+ }
+
+ self.terminal.update(cx, |term, _| term.scroll_line_up());
+ // Terminal scrollback and block scrolling are mutually exclusive.
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ cx.notify();
+ }
+
+ pub(super) fn scroll_line_down(
+ &mut self,
+ _: &ScrollLineDown,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let TerminalScrollState {
+ is_remote_mirror,
+ display_offset,
+ line_height,
+ } = self.terminal_scroll_state(cx);
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_line_down());
+ return;
+ }
+ if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
+ let max_scroll_top = self.max_scroll_top(cx);
+ if self.scroll.scroll_top < max_scroll_top {
+ self.scroll.scroll_top =
+ cmp::min(self.scroll.scroll_top + line_height, max_scroll_top);
+ }
+ self.scroll.stick_to_bottom =
+ self.scroll.scroll_top + line_height / 2.0 >= max_scroll_top;
+ return;
+ }
+
+ self.terminal.update(cx, |term, _| term.scroll_line_down());
+ // Terminal scrollback and block scrolling are mutually exclusive.
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ cx.notify();
+ }
+
+ pub(super) fn scroll_page_up(
+ &mut self,
+ _: &ScrollPageUp,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let (is_remote_mirror, line_height, viewport_lines) = {
+ let terminal = self.terminal.read(cx);
+ (
+ terminal.is_remote_mirror(),
+ terminal.last_content().terminal_bounds.line_height(),
+ terminal.viewport_lines(),
+ )
+ };
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_page_up());
+ return;
+ }
+ if self.scroll.scroll_top == Pixels::ZERO {
+ self.terminal.update(cx, |term, _| term.scroll_page_up());
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ } else {
+ let visible_block_lines = (self.scroll.scroll_top / line_height) as usize;
+ let visible_content_lines = viewport_lines - visible_block_lines;
+
+ if visible_block_lines >= viewport_lines {
+ self.scroll.scroll_top =
+ ((visible_block_lines - viewport_lines) as f32) * line_height;
+ } else {
+ self.scroll.scroll_top = px(0.);
+ self.terminal
+ .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
+ }
+ self.scroll.stick_to_bottom =
+ self.scroll.scroll_top + line_height / 2.0 >= self.max_scroll_top(cx);
+ }
+ cx.notify();
+ }
+
+ pub(super) fn scroll_page_down(
+ &mut self,
+ _: &ScrollPageDown,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let TerminalScrollState {
+ is_remote_mirror,
+ display_offset,
+ ..
+ } = self.terminal_scroll_state(cx);
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_page_down());
+ return;
+ }
+ self.terminal.update(cx, |term, _| term.scroll_page_down());
+ // Scrolling the terminal history should not apply block scrolling offsets.
+ // `scroll_top` is only meaningful while we're at the live view.
+ if self.scroll.block_below_cursor.is_some() && display_offset == 0 {
+ self.scroll.scroll_top = self.max_scroll_top(cx);
+ self.scroll.stick_to_bottom = true;
+ } else {
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ }
+ cx.notify();
+ }
+
+ pub(super) fn scroll_to_top(
+ &mut self,
+ _: &ScrollToTop,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror;
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_to_top());
+ return;
+ }
+ self.terminal.update(cx, |term, _| term.scroll_to_top());
+ self.scroll.scroll_top = Pixels::ZERO;
+ self.scroll.stick_to_bottom = false;
+ cx.notify();
+ }
+
+ pub(super) fn scroll_to_bottom(
+ &mut self,
+ _: &ScrollToBottom,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.reveal_scrollbar_for_scroll(window, cx);
+ let is_remote_mirror = self.terminal_scroll_state(cx).is_remote_mirror;
+ if is_remote_mirror {
+ self.terminal.update(cx, |term, _| term.scroll_to_bottom());
+ return;
+ }
+ self.terminal.update(cx, |term, _| term.scroll_to_bottom());
+ if self.scroll.block_below_cursor.is_some() {
+ self.scroll.scroll_top = self.max_scroll_top(cx);
+ } else {
+ self.scroll.scroll_top = Pixels::ZERO;
+ }
+ self.scroll.stick_to_bottom = true;
+ cx.notify();
+ }
+
+ pub(super) fn toggle_vi_mode(
+ &mut self,
+ _: &ToggleViMode,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.terminal.update(cx, |term, _| term.toggle_vi_mode());
+ cx.notify();
+ }
+}
+
#[cfg(test)]
mod tests {
- use super::*;
+ use gpui::{Bounds, point, px, size};
+
+ use super::{
+ buffer_index_for_line_coord, scroll_offset_for_line_coord_centered,
+ scrollbar_marker_y_for_line_coord, search_match_index_for_scrollbar_click,
+ search_match_index_for_scrollbar_hover,
+ };
+ use crate::GridPoint;
#[test]
fn scrollbar_marker_y_maps_entire_buffer_top_to_bottom() {
diff --git a/crates/gpui_term/src/view/search.rs b/crates/gpui_term/src/view/search.rs
index dca04b9..c72fbb2 100644
--- a/crates/gpui_term/src/view/search.rs
+++ b/crates/gpui_term/src/view/search.rs
@@ -1,21 +1,27 @@
+use std::{ops::Range, time::Duration};
+
use gpui::{
- AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
- MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ReadGlobal, Styled, Window, div, point,
- px,
+ AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
+ MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ReadGlobal, Styled,
+ Window, div, point, px,
};
use gpui_component::ActiveTheme;
+use smol::Timer;
use unicode_segmentation::UnicodeSegmentation;
use super::{
- ImeState, TerminalView,
- scrollbar::{
+ ImeState, SearchOverlayDelete, SearchOverlayKeyDown, SearchOverlayMove, TerminalView,
+ scrolling::{
SCROLLBAR_WIDTH, scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y,
scrollbar_bounds_for_terminal, scrollbar_track_bounds,
search_match_index_for_scrollbar_click, search_match_index_for_scrollbar_hover,
thumb_bounds_for_track,
},
};
-use crate::{settings::TerminalSettings, terminal::SearchClose};
+use crate::{
+ settings::TerminalSettings,
+ terminal::{Search, SearchClose, SearchNext, SearchPaste, SearchPrevious},
+};
fn search_match_counter(view: &TerminalView, cx: &mut Context) -> String {
let terminal = view.terminal.read(cx);
@@ -662,6 +668,329 @@ pub(crate) fn render_search(
)
}
+impl TerminalView {
+ pub(super) fn open_search(&mut self, _: &Search, window: &mut Window, cx: &mut Context) {
+ self.search.search_open = true;
+ self.search.search_panel_dragging = false;
+ self.search.search_panel_drag_start_mouse = None;
+ self.search.search_panel_drag_start_pos = None;
+ self.search.search_expected_commit = None;
+ self.search.search.end();
+
+ // Default position: near the top, centered within the current window.
+ let viewport = window.viewport_size();
+ let panel_w = px(520.0)
+ .min((viewport.width - px(24.0)).max(Pixels::ZERO))
+ .max(px(320.0).min(viewport.width.max(Pixels::ZERO)));
+ let keep = px(32.0);
+ if !self.search.search_panel_pos_initialized {
+ let x = (viewport.width - panel_w).max(Pixels::ZERO) / 2.0;
+ self.search.search_panel_pos = gpui::point(x, px(72.0));
+ self.search.search_panel_pos_initialized = true;
+ } else {
+ // If the window size changed since the last open, keep the panel reachable.
+ let mut pos = self.search.search_panel_pos;
+ pos.x = pos
+ .x
+ .clamp((Pixels::ZERO - panel_w) + keep, viewport.width - keep);
+ pos.y = pos.y.clamp(px(0.0), viewport.height - keep);
+ self.search.search_panel_pos = pos;
+ }
+
+ window.focus(&self.focus_handle, cx);
+ cx.notify();
+ }
+
+ pub(super) fn close_search(
+ &mut self,
+ _: &SearchClose,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.search.search_open = false;
+ self.clear_scrollbar_preview(cx);
+ self.search.search_epoch = self.search.search_epoch.wrapping_add(1);
+ self.search.search.clear();
+ self.search.search_ime_state = None;
+ self.search.search_expected_commit = None;
+ self.search.search_panel_dragging = false;
+ self.search.search_panel_drag_start_mouse = None;
+ self.search.search_panel_drag_start_pos = None;
+
+ window.focus(&self.focus_handle, cx);
+
+ self.terminal.update(cx, |term, _| {
+ term.set_search_query(None);
+ term.select_matches(&[]);
+ });
+ cx.notify();
+ }
+
+ pub(super) fn search_next(
+ &mut self,
+ _: &SearchNext,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_search(true, cx);
+ }
+
+ pub(super) fn search_previous(
+ &mut self,
+ _: &SearchPrevious,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.jump_search(false, cx);
+ }
+
+ pub(super) fn jump_search(&mut self, forward: bool, cx: &mut Context) {
+ self.terminal.update(cx, |term, _| {
+ let matches_len = term.matches().len();
+ if matches_len == 0 {
+ return;
+ }
+
+ let cur = term.active_match_index().unwrap_or(0) % matches_len;
+ let next = if forward {
+ (cur + 1) % matches_len
+ } else {
+ cur.checked_sub(1).unwrap_or(matches_len - 1)
+ };
+ term.activate_match(next);
+ term.jump_to_match(next);
+ });
+ cx.notify();
+ }
+
+ pub(super) fn search_paste(&mut self, _: &SearchPaste, _: &mut Window, cx: &mut Context) {
+ if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
+ self.search.search.insert(&text);
+ self.schedule_search_update(cx);
+ }
+ }
+
+ pub(crate) fn commit_search_text(&mut self, text: &str, cx: &mut Context) {
+ if text.is_empty() {
+ return;
+ }
+ if self.search.search_expected_commit.as_deref() == Some(text) {
+ self.search.search_expected_commit = None;
+ return;
+ }
+ self.search.search_expected_commit = None;
+ self.search.search_ime_state = None;
+ self.search.search.insert(text);
+ self.schedule_search_update(cx);
+ }
+
+ pub(crate) fn set_search_marked_text(
+ &mut self,
+ text: String,
+ range: Option>,
+ cx: &mut Context,
+ ) {
+ self.search.search_ime_state = Some(ImeState {
+ marked_text: text,
+ marked_range_utf16: range,
+ });
+ cx.notify();
+ }
+
+ pub(crate) fn clear_search_marked_text(&mut self, cx: &mut Context) {
+ self.search.search_ime_state = None;
+ cx.notify();
+ }
+
+ pub(crate) fn is_search_open(&self) -> bool {
+ self.search.search_open
+ }
+
+ pub(crate) fn search_marked_text_range(&self) -> Option> {
+ self.search
+ .search_ime_state
+ .as_ref()
+ .and_then(|state| state.marked_range_utf16.clone())
+ }
+
+ pub(crate) fn search_panel_pos(&self) -> gpui::Point {
+ self.search.search_panel_pos
+ }
+
+ fn schedule_search_update(&mut self, cx: &mut Context) {
+ let epoch = self.search.search_epoch.wrapping_add(1);
+ self.search.search_epoch = epoch;
+ cx.spawn(async move |this, cx| {
+ Timer::after(Duration::from_millis(150)).await;
+ let _ = this.update(cx, |this, cx| {
+ if !this.search.search_open || this.search.search_epoch != epoch {
+ return;
+ }
+
+ // Only treat all-whitespace as empty; otherwise keep the query exactly as typed.
+ let q = this.search.search.text().to_string();
+ this.terminal.update(cx, |term, _| {
+ if q.chars().all(|c| c.is_whitespace()) {
+ term.set_search_query(None);
+ } else {
+ term.set_search_query(Some(q));
+ if !term.matches().is_empty() {
+ term.activate_match(0);
+ }
+ }
+ });
+ cx.notify();
+ });
+ })
+ .detach();
+ }
+
+ pub(crate) fn search_cursor_utf16(&self) -> usize {
+ self.search.search.cursor_utf16()
+ }
+
+ pub(super) fn handle_search_overlay_key_down_for_terminal_key_down(
+ &mut self,
+ event: &KeyDownEvent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> bool {
+ match self.handle_search_overlay_key_down(event, window, cx) {
+ SearchOverlayKeyDown::NotOpen => false,
+ SearchOverlayKeyDown::Return => true,
+ SearchOverlayKeyDown::StopAndReturn => {
+ // Search is a overlay; don't forward keystrokes to the terminal.
+ cx.stop_propagation();
+ true
+ }
+ }
+ }
+
+ fn handle_search_overlay_key_down(
+ &mut self,
+ event: &KeyDownEvent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> SearchOverlayKeyDown {
+ if !self.search.search_open {
+ return SearchOverlayKeyDown::NotOpen;
+ }
+
+ match event.keystroke.key.as_str() {
+ "escape" => {
+ self.close_search(&SearchClose, window, cx);
+ }
+ "enter" => {
+ self.jump_search(!event.keystroke.modifiers.shift, cx);
+ }
+ "left" => self.search_overlay_move_cursor(SearchOverlayMove::Left, event, cx),
+ "right" => self.search_overlay_move_cursor(SearchOverlayMove::Right, event, cx),
+ "home" => self.search_overlay_move_cursor(SearchOverlayMove::Home, event, cx),
+ "end" => self.search_overlay_move_cursor(SearchOverlayMove::End, event, cx),
+ "backspace" => self.search_overlay_delete(SearchOverlayDelete::Prev, event, cx),
+ "delete" => self.search_overlay_delete(SearchOverlayDelete::Next, event, cx),
+ // Common terminal muscle memory: cmd/ctrl+a moves to start of the input.
+ "a" if event.keystroke.modifiers.secondary() => {
+ self.search.search.home();
+ cx.notify();
+ }
+ // Support cmd/ctrl+v paste into the query even if keybinding dispatch is skipped.
+ "v" if event.keystroke.modifiers.secondary() => {
+ self.search_paste(&SearchPaste, window, cx);
+ }
+ // Support cmd/ctrl+g next/prev even if keybinding dispatch is skipped.
+ "g" if event.keystroke.modifiers.secondary() => {
+ self.jump_search(!event.keystroke.modifiers.shift, cx);
+ }
+ _ => {
+ // Text input (including IME) is handled via the InputHandler installed by the
+ // terminal element while the search is open.
+ //
+ // However, on some platforms plain latin text does *not* go through the IME
+ // callbacks; in that case we fall back to `key_char`.
+ if self.search_overlay_is_composing(event) {
+ // IME is actively composing; don't insert raw keystrokes.
+ return SearchOverlayKeyDown::Return;
+ }
+
+ if event.keystroke.modifiers.control
+ || event.keystroke.modifiers.platform
+ || event.keystroke.modifiers.function
+ || event.keystroke.modifiers.alt
+ {
+ return SearchOverlayKeyDown::Return;
+ }
+
+ if let Some(ch) = event.keystroke.key_char.as_ref()
+ && !ch.is_empty()
+ {
+ self.search.search_expected_commit = Some(ch.clone());
+ self.search.search.insert(ch);
+ self.schedule_search_update(cx);
+ }
+ }
+ }
+
+ SearchOverlayKeyDown::StopAndReturn
+ }
+
+ fn search_overlay_is_composing(&self, event: &KeyDownEvent) -> bool {
+ self.search_overlay_has_marked_text() || event.keystroke.is_ime_in_progress()
+ }
+
+ fn search_overlay_has_marked_text(&self) -> bool {
+ self.search
+ .search_ime_state
+ .as_ref()
+ .is_some_and(|ime| !ime.marked_text.is_empty())
+ }
+
+ fn search_overlay_move_cursor(
+ &mut self,
+ movement: SearchOverlayMove,
+ event: &KeyDownEvent,
+ cx: &mut Context,
+ ) {
+ if self.search_overlay_is_composing(event) {
+ // Let the IME handle caret movement inside an active composition.
+ return;
+ }
+
+ match movement {
+ SearchOverlayMove::Left => self.search.search.move_left(),
+ SearchOverlayMove::Right => self.search.search.move_right(),
+ SearchOverlayMove::Home => self.search.search.home(),
+ SearchOverlayMove::End => self.search.search.end(),
+ }
+ cx.notify();
+ }
+
+ fn search_overlay_delete(
+ &mut self,
+ delete: SearchOverlayDelete,
+ event: &KeyDownEvent,
+ cx: &mut Context,
+ ) {
+ let should_ignore = match delete {
+ SearchOverlayDelete::Prev => self.search_overlay_has_marked_text(),
+ SearchOverlayDelete::Next => self.search_overlay_is_composing(event),
+ };
+ if should_ignore {
+ // Let the platform IME drive composition edits via the InputHandler.
+ return;
+ }
+
+ let changed = match delete {
+ SearchOverlayDelete::Prev => self.search.search.delete_prev(),
+ SearchOverlayDelete::Next => self.search.search.delete_next(),
+ };
+ if changed {
+ self.schedule_search_update(cx);
+ }
+ cx.notify();
+ }
+}
+
#[cfg(test)]
mod tests {
use super::SearchBuffer;
diff --git a/crates/gpui_term/src/view/suggestions.rs b/crates/gpui_term/src/view/suggestions.rs
new file mode 100644
index 0000000..7110d60
--- /dev/null
+++ b/crates/gpui_term/src/view/suggestions.rs
@@ -0,0 +1,452 @@
+use std::time::Duration;
+
+use gpui::{App, Context, KeyDownEvent, Keystroke, ReadGlobal};
+use smol::Timer;
+
+use super::TerminalView;
+use crate::{
+ TerminalContent, TerminalMode,
+ settings::TerminalSettings,
+ snippet::{SnippetSession, parse_snippet_suffix},
+ suggestions::{
+ SelectionMove, SuggestionItem, SuggestionStaticConfig, compute_insert_suffix_for_line,
+ extract_cursor_line_prefix, extract_cursor_line_suffix, line_is_suggestion_prefix,
+ move_selection_opt,
+ },
+};
+
+impl TerminalView {
+ pub(crate) fn suggestions_snapshot(&self) -> Option<(Vec, Option)> {
+ self.suggestions.open.then(|| {
+ let highlighted = self
+ .suggestions
+ .hovered
+ .or(self.suggestions.selected)
+ .and_then(|highlighted| {
+ let last = self.suggestions.items.len().saturating_sub(1);
+ (!self.suggestions.items.is_empty()).then_some(highlighted.min(last))
+ });
+ (self.suggestions.items.clone(), highlighted)
+ })
+ }
+
+ pub(crate) fn close_suggestions(&mut self, cx: &mut Context) {
+ if !self.suggestions.open {
+ return;
+ }
+ self.suggestions.close();
+ cx.notify();
+ }
+
+ pub(crate) fn set_suggestions_hovered(
+ &mut self,
+ hovered: Option,
+ cx: &mut Context,
+ ) {
+ if !self.suggestions.open {
+ if self.suggestions.hovered.take().is_some() {
+ cx.notify();
+ }
+ return;
+ }
+
+ let hovered = hovered.and_then(|idx| {
+ let last = self.suggestions.items.len().saturating_sub(1);
+ (!self.suggestions.items.is_empty()).then_some(idx.min(last))
+ });
+
+ if self.suggestions.hovered != hovered {
+ self.suggestions.hovered = hovered;
+ cx.notify();
+ }
+ }
+
+ pub(super) fn handle_suggestions_key_down(
+ &mut self,
+ event: &KeyDownEvent,
+ cx: &mut Context,
+ ) -> bool {
+ // Suggestions are intentionally conservative: remote/SSH sessions have no shell
+ // integration, so we only show/accept append-only hints in shell-like contexts.
+ if !TerminalSettings::global(cx).suggestions_enabled || self.snippet.is_some() {
+ return false;
+ }
+
+ let Some(prompt) = self.prompt_context(cx) else {
+ return false;
+ };
+ if !self.suggestions_eligible_for_content(&prompt.content, cx) {
+ self.suggestions.prompt_prefix = None;
+ self.suggestions.close();
+ return false;
+ }
+
+ match event.keystroke.key.as_str() {
+ "escape" if self.suggestions.open => {
+ self.suggestions.close();
+ cx.notify();
+ cx.stop_propagation();
+ true
+ }
+ "up" if self.suggestions.open => {
+ self.suggestions.selected = move_selection_opt(
+ self.suggestions.selected,
+ self.suggestions.items.len(),
+ SelectionMove::Up,
+ );
+ cx.notify();
+ cx.stop_propagation();
+ true
+ }
+ "down" if self.suggestions.open => {
+ self.suggestions.selected = move_selection_opt(
+ self.suggestions.selected,
+ self.suggestions.items.len(),
+ SelectionMove::Down,
+ );
+ cx.notify();
+ cx.stop_propagation();
+ true
+ }
+ "enter" => {
+ if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) {
+ cx.stop_propagation();
+ return true;
+ }
+
+ if let Some(prompt_prefix) = self.suggestions.prompt_prefix.take() {
+ let line_prefix = extract_cursor_line_prefix(&prompt.content);
+ let input = line_prefix
+ .strip_prefix(&prompt_prefix)
+ .unwrap_or("")
+ .trim()
+ .to_string();
+ if !input.is_empty() {
+ self.queue_command_for_history(input, cx);
+ }
+ }
+ self.suggestions.close();
+ false
+ }
+ "right" => {
+ if self.accept_selected_suggestion(&prompt.content, prompt.cursor_line_id, cx) {
+ cx.stop_propagation();
+ return true;
+ }
+ false
+ }
+ "backspace" => {
+ if self.suggestions.prompt_prefix.is_none() {
+ self.suggestions.prompt_prefix =
+ Some(extract_cursor_line_prefix(&prompt.content));
+ }
+ self.schedule_suggestions_update(cx);
+ false
+ }
+ _ => {
+ let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| {
+ !ch.is_empty()
+ && !event.keystroke.is_ime_in_progress()
+ && !event.keystroke.modifiers.control
+ && !event.keystroke.modifiers.platform
+ && !event.keystroke.modifiers.function
+ && !event.keystroke.modifiers.alt
+ });
+
+ if is_plain_text {
+ if self.suggestions.prompt_prefix.is_none() {
+ self.suggestions.prompt_prefix =
+ Some(extract_cursor_line_prefix(&prompt.content));
+ }
+ self.schedule_suggestions_update(cx);
+ } else if self.suggestions.open {
+ self.suggestions.close();
+ }
+ false
+ }
+ }
+ }
+
+ pub(super) fn suggestions_eligible_for_content(
+ &self,
+ content: &TerminalContent,
+ cx: &App,
+ ) -> bool {
+ TerminalSettings::global(cx).suggestions_enabled
+ && content.display_offset == 0
+ && !content.mode.contains(TerminalMode::ALT_SCREEN)
+ && content.selection.is_none()
+ && self.scroll.block_below_cursor.is_none()
+ }
+
+ fn schedule_suggestions_update(&mut self, cx: &mut Context) {
+ let epoch = self.suggestions.epoch.wrapping_add(1);
+ self.suggestions.epoch = epoch;
+ cx.spawn(async move |this, cx| {
+ Timer::after(Duration::from_millis(200)).await;
+ let _ = this.update(cx, |this, cx| {
+ if this.suggestions.epoch != epoch {
+ return;
+ }
+
+ let Some(prompt) = this.prompt_context(cx) else {
+ return;
+ };
+ if !this.suggestions_eligible_for_content(&prompt.content, cx) {
+ this.suggestions.prompt_prefix = None;
+ this.suggestions.close();
+ cx.notify();
+ return;
+ }
+
+ let Some(prompt_prefix) = this.suggestions.prompt_prefix.clone() else {
+ this.suggestions.close();
+ cx.notify();
+ return;
+ };
+
+ let line_prefix = extract_cursor_line_prefix(&prompt.content);
+ let input_prefix = line_prefix.strip_prefix(&prompt_prefix).unwrap_or("");
+
+ this.suggestions.engine.max_items =
+ TerminalSettings::global(cx).suggestions_max_items;
+
+ if let Some(cfg) = cx.try_global::()
+ && cfg.epoch != this.suggestions.static_epoch_seen
+ {
+ this.suggestions.static_epoch_seen = cfg.epoch;
+ this.suggestions
+ .engine
+ .set_static_provider(cfg.provider.clone());
+ }
+
+ let items = this.suggestions.engine.suggest(input_prefix);
+ this.suggestions.open_with_items(items);
+ cx.notify();
+ });
+ })
+ .detach();
+ }
+
+ pub(super) fn accept_selected_suggestion(
+ &mut self,
+ content: &TerminalContent,
+ cursor_line_id: Option,
+ cx: &mut Context,
+ ) -> bool {
+ if !self.suggestions.open {
+ return false;
+ }
+
+ let Some(selected) = self.suggestions.selected else {
+ return false;
+ };
+ self.accept_suggestion_at_index(selected, content, cursor_line_id, cx)
+ }
+
+ pub(crate) fn accept_suggestion_at_index(
+ &mut self,
+ index: usize,
+ content: &TerminalContent,
+ cursor_line_id: Option,
+ cx: &mut Context,
+ ) -> bool {
+ if !self.suggestions.open {
+ return false;
+ }
+
+ let Some(item) = self.suggestions.items.get(index).cloned() else {
+ return false;
+ };
+
+ let line_prefix = extract_cursor_line_prefix(content);
+ let Some((input_prefix, suffix_template)) = compute_insert_suffix_for_line(
+ &line_prefix,
+ self.suggestions.prompt_prefix.as_deref(),
+ &item.full_text,
+ ) else {
+ return false;
+ };
+
+ let line_suffix = extract_cursor_line_suffix(content);
+ if !line_suffix.trim().is_empty() {
+ let combined_line = format!("{input_prefix}{line_suffix}");
+ if line_is_suggestion_prefix(&combined_line, &item.full_text) {
+ return false;
+ }
+ }
+
+ let mut suffix_rendered = suffix_template.clone();
+ let mut snippet_session: Option = None;
+ let mut initial_move_left = 0usize;
+
+ if let Some(snippet) = parse_snippet_suffix(&suffix_template) {
+ suffix_rendered = snippet.rendered;
+
+ // `$0` is a cursor position, not an editable placeholder. Avoid entering snippet mode
+ // if there are no non-zero tabstops.
+ if snippet.tabstops.iter().any(|t| t.index != 0) {
+ let mut session = SnippetSession::new(suffix_rendered.clone(), snippet.tabstops);
+ session.cursor_line_id = cursor_line_id;
+ session.start_point = content.cursor.point;
+ session.active = 0;
+
+ let target_end = session
+ .tabstops
+ .first()
+ .map(|t| t.range_chars.end)
+ .unwrap_or(session.inserted_len_chars);
+
+ initial_move_left = session.inserted_len_chars.saturating_sub(target_end);
+ session.cursor_offset_chars = target_end;
+ session.selected = true;
+ snippet_session = Some(session);
+ }
+ }
+
+ self.snap_to_bottom_on_input(cx);
+ let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
+ let left = Keystroke::parse("left").unwrap();
+
+ let suffix = suffix_rendered.into_bytes();
+ self.terminal.update(cx, move |term, _| {
+ term.input(suffix);
+ for _ in 0..initial_move_left {
+ term.try_keystroke(&left, alt_is_meta);
+ }
+ });
+ self.snippet = snippet_session;
+ self.suggestions.close();
+ true
+ }
+
+ pub(crate) fn snippet_snapshot_for_content(
+ &self,
+ content: &TerminalContent,
+ cursor_line_id: Option,
+ cx: &App,
+ ) -> Option {
+ let snippet = self.snippet.clone()?;
+ if !self.suggestions_eligible_for_content(content, cx) {
+ return None;
+ }
+
+ if let Some(expected) = snippet.cursor_line_id
+ && cursor_line_id != Some(expected)
+ {
+ return None;
+ }
+
+ Some(snippet)
+ }
+
+ pub(super) fn handle_snippet_key_down(
+ &mut self,
+ event: &KeyDownEvent,
+ cx: &mut Context,
+ ) -> bool {
+ if self.snippet.is_none() {
+ return false;
+ }
+
+ let eligible = self
+ .prompt_context(cx)
+ .is_some_and(|prompt| self.snippet_prompt_is_eligible(&prompt, cx));
+
+ if !eligible {
+ self.snippet = None;
+ return false;
+ }
+
+ // Any newline ends the snippet session.
+ if event.keystroke.key.as_str() == "enter" {
+ self.snippet = None;
+ return false;
+ }
+
+ let is_plain_text = event.keystroke.key_char.as_ref().is_some_and(|ch| {
+ !ch.is_empty()
+ && !event.keystroke.is_ime_in_progress()
+ && !event.keystroke.modifiers.control
+ && !event.keystroke.modifiers.platform
+ && !event.keystroke.modifiers.function
+ && !event.keystroke.modifiers.alt
+ });
+ if is_plain_text
+ && !matches!(event.keystroke.key.as_str(), "tab" | "escape")
+ && let Some(ch) = event.keystroke.key_char.as_deref()
+ && !ch.is_empty()
+ {
+ // Snippet placeholder "selection" is local UI state; terminal line editors do not
+ // support replacing highlighted ranges. Treat character input as an explicit
+ // commit so we can delete/replace the active placeholder and keep placeholder
+ // highlight ranges in sync.
+ self.commit_text(ch, cx);
+ cx.notify();
+ cx.stop_propagation();
+ return true;
+ }
+
+ let Some(session) = self.snippet.as_mut() else {
+ return false;
+ };
+
+ if event.keystroke.key.as_str() == "backspace" {
+ if session.selected {
+ let deleted_chars = session.delete_active_placeholder();
+ session.selected = false;
+
+ if deleted_chars > 0 {
+ let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
+ let backspace = Keystroke::parse("backspace").unwrap();
+ self.terminal.update(cx, move |term, _| {
+ for _ in 0..deleted_chars {
+ term.try_keystroke(&backspace, alt_is_meta);
+ }
+ });
+ cx.notify();
+ cx.stop_propagation();
+ return true;
+ }
+ } else {
+ let deleted = session.backspace_one_in_active_placeholder();
+ if deleted {
+ let alt_is_meta = TerminalSettings::global(cx).option_as_meta;
+ let backspace = Keystroke::parse("backspace").unwrap();
+ self.terminal.update(cx, move |term, _| {
+ term.try_keystroke(&backspace, alt_is_meta);
+ });
+ cx.notify();
+ cx.stop_propagation();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Any unexpected navigation/editing key cancels snippet mode to avoid
+ // desync with the remote line editor state.
+ let key = event.keystroke.key.as_str();
+ let cancel = event.keystroke.modifiers.control
+ || event.keystroke.modifiers.platform
+ || event.keystroke.modifiers.function
+ || event.keystroke.modifiers.alt
+ || matches!(
+ key,
+ "left"
+ | "right"
+ | "up"
+ | "down"
+ | "home"
+ | "end"
+ | "pageup"
+ | "pagedown"
+ | "delete"
+ );
+ if cancel {
+ self.snippet = None;
+ }
+
+ false
+ }
+}
diff --git a/locales/en.yml b/locales/en.yml
index c7094cb..2b59d96 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -82,7 +82,6 @@ NewSession:
Field:
AuthType: "Auth Type:"
Password: "Password:"
- Sftp: "SFTP:"
Error:
HostNoUserAtInConfigMode: "Host must not include user@ in SSH Config mode."
PortRange: "Port must be 1-65535."
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 18443b4..d061890 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -81,7 +81,6 @@ NewSession:
Field:
AuthType: "认证方式:"
Password: "密码:"
- Sftp: "SFTP:"
Error:
HostNoUserAtInConfigMode: "SSH 配置模式下 Host 不能包含 user@。"
PortRange: "端口必须为 1-65535。"
diff --git a/termua/Cargo.toml b/termua/Cargo.toml
index 256e06f..838cac7 100644
--- a/termua/Cargo.toml
+++ b/termua/Cargo.toml
@@ -36,6 +36,7 @@ home.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
+thiserror.workspace = true
rusqlite.workspace = true
libc.workspace = true
rust-i18n.workspace = true
diff --git a/termua/src/panel/sessions_sidebar/icons.rs b/termua/src/panel/sessions_sidebar/icons.rs
index a53629f..bba3ca2 100644
--- a/termua/src/panel/sessions_sidebar/icons.rs
+++ b/termua/src/panel/sessions_sidebar/icons.rs
@@ -1,8 +1,6 @@
use std::collections::BTreeMap;
-use gpui::{
- AnyElement, InteractiveElement, IntoElement, ParentElement, Styled, StyledImage, div, img, px,
-};
+use gpui::{AnyElement, InteractiveElement, IntoElement, ParentElement, Styled, div, px};
use gpui_common::TermuaIcon;
use gpui_component::Icon;
@@ -11,46 +9,22 @@ use crate::store::{Session, SessionType};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SessionIconKind {
Terminal,
- Nushell,
- Pwsh,
- Fish,
- Sh,
}
impl SessionIconKind {
fn icon_path(self) -> TermuaIcon {
- match self {
- Self::Terminal => TermuaIcon::Terminal,
- Self::Nushell => TermuaIcon::Nushell,
- Self::Pwsh => TermuaIcon::Pwsh,
- Self::Fish => TermuaIcon::Fish,
- Self::Sh => TermuaIcon::Sh,
- }
+ TermuaIcon::Terminal
}
pub(super) fn into_element_for_session_id(self, session_id: i64) -> AnyElement {
- match self {
- Self::Terminal | Self::Nushell | Self::Fish | Self::Sh => div()
- .w(px(16.))
- .h(px(16.))
- .flex_shrink_0()
- .debug_selector(move || match self {
- Self::Terminal => format!("termua-sessions-session-icon-local-{session_id}"),
- Self::Nushell => format!("termua-sessions-session-icon-nushell-{session_id}"),
- Self::Fish => format!("termua-sessions-session-icon-fish-{session_id}"),
- Self::Sh => format!("termua-sessions-session-icon-sh-{session_id}"),
- Self::Pwsh => unreachable!(),
- })
- .child(Icon::default().path(self.icon_path()).size_4())
- .into_any_element(),
- Self::Pwsh => img(TermuaIcon::Pwsh)
- .w(px(16.))
- .h(px(16.))
- .flex_shrink_0()
- .object_fit(gpui::ObjectFit::Contain)
- .debug_selector(move || format!("termua-sessions-session-icon-pwsh-{session_id}"))
- .into_any_element(),
- }
+ let _ = self;
+ div()
+ .w(px(16.))
+ .h(px(16.))
+ .flex_shrink_0()
+ .debug_selector(move || format!("termua-sessions-session-icon-local-{session_id}"))
+ .child(Icon::default().path(self.icon_path()).size_4())
+ .into_any_element()
}
}
@@ -61,45 +35,7 @@ pub(super) fn build_session_icon_kinds(sessions: &[Session]) -> BTreeMap SessionIconKind::Sh,
- "nu" | "nushell" => SessionIconKind::Nushell,
- "pwsh" | "powershell" => SessionIconKind::Pwsh,
- "fish" => SessionIconKind::Fish,
- _ => SessionIconKind::Terminal,
- })
- .unwrap();
-
- out.insert(session.id, kind);
+ out.insert(session.id, SessionIconKind::Terminal);
}
out
}
-
-fn shell_program_basename(program: &str) -> Option<&str> {
- let trimmed = program.trim();
- if trimmed.is_empty() {
- return None;
- }
-
- let last_slash = trimmed.rfind('/');
- let last_backslash = trimmed.rfind('\\');
- let start = match (last_slash, last_backslash) {
- (Some(a), Some(b)) => a.max(b) + 1,
- (Some(a), None) => a + 1,
- (None, Some(b)) => b + 1,
- (None, None) => 0,
- };
-
- Some(&trimmed[start..])
-}
-
-fn strip_exe_suffix(program: &str) -> &str {
- program.strip_suffix(".exe").unwrap_or(program)
-}
diff --git a/termua/src/panel/sessions_sidebar/tests.rs b/termua/src/panel/sessions_sidebar/tests.rs
index e05df8c..0e0b370 100644
--- a/termua/src/panel/sessions_sidebar/tests.rs
+++ b/termua/src/panel/sessions_sidebar/tests.rs
@@ -38,19 +38,13 @@ fn folder_icons_toggle_with_expansion(cx: &mut gpui::TestAppContext) {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-folder-icons-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-folder-icons");
let _guard = crate::store::tests::override_termua_db_path(db_path);
crate::store::save_local_session(
"Group",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -104,19 +98,13 @@ fn local_session_icon_is_debuggable(cx: &mut gpui::TestAppContext) {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-local-icon-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-local-icon");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -162,19 +150,13 @@ fn sessions_open_only_on_double_click(cx: &mut gpui::TestAppContext) {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-double-click-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-double-click");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -258,12 +240,7 @@ fn ssh_sessions_show_connecting_and_block_repeat_double_click(cx: &mut gpui::Tes
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-connecting-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-connecting");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_ssh_session_password(
@@ -395,19 +372,13 @@ fn sessions_can_be_deleted_via_right_click_menu(cx: &mut gpui::TestAppContext) {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-delete-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-delete");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -562,7 +533,6 @@ fn build_tree_items_filters_by_query_and_keeps_ancestors() {
label: "db".to_string(),
backend: crate::settings::TerminalBackend::Wezterm,
env: test_session_env("xterm", "UTF-8", None),
- shell_program: None,
ssh_host: Some("db.example.com".to_string()),
ssh_port: Some(22),
ssh_auth_type: None,
@@ -590,7 +560,6 @@ fn build_tree_items_filters_by_query_and_keeps_ancestors() {
label: "api".to_string(),
backend: crate::settings::TerminalBackend::Wezterm,
env: test_session_env("xterm", "UTF-8", None),
- shell_program: None,
ssh_host: Some("api.example.com".to_string()),
ssh_port: Some(22),
ssh_auth_type: None,
@@ -644,19 +613,13 @@ fn sessions_context_menu_includes_edit_item(cx: &mut gpui::TestAppContext) {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-edit-menu-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-edit-menu");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -719,24 +682,18 @@ fn sessions_context_menu_includes_edit_item(cx: &mut gpui::TestAppContext) {
}
#[gpui::test]
-fn powershell_sessions_show_pwsh_icon(cx: &mut gpui::TestAppContext) {
+fn local_sessions_always_show_terminal_icon(cx: &mut gpui::TestAppContext) {
cx.update(|app| {
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-pwsh-icon-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-pwsh-icon");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"powershell",
crate::settings::TerminalBackend::Wezterm,
- "pwsh",
"xterm-256color",
"UTF-8",
)
@@ -758,54 +715,9 @@ fn powershell_sessions_show_pwsh_icon(cx: &mut gpui::TestAppContext) {
cx.run_until_parked();
let icon_selector: &'static str =
- Box::leak(format!("termua-sessions-session-icon-pwsh-{session_id}").into_boxed_str());
- cx.debug_bounds(icon_selector)
- .expect("expected PowerShell sessions to render pwsh.svg as their icon");
-}
-
-#[gpui::test]
-fn nushell_sessions_show_nushell_icon(cx: &mut gpui::TestAppContext) {
- cx.update(|app| {
- gpui_component::init(app);
- });
-
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-nushell-icon-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
- let _guard = crate::store::tests::override_termua_db_path(db_path);
-
- let session_id = crate::store::save_local_session(
- "local",
- "nushell",
- crate::settings::TerminalBackend::Wezterm,
- "nu",
- "xterm-256color",
- "UTF-8",
- )
- .unwrap();
-
- let (root, cx) = cx.add_window_view(|window, cx| {
- let sidebar = cx.new(|cx| SessionsSidebarView::new(window, cx));
- gpui_component::Root::new(sidebar, window, cx)
- });
-
- cx.draw(
- gpui::point(gpui::px(0.), gpui::px(0.)),
- gpui::size(
- gpui::AvailableSpace::Definite(gpui::px(600.)),
- gpui::AvailableSpace::Definite(gpui::px(400.)),
- ),
- move |_, _| div().size_full().child(root),
- );
- cx.run_until_parked();
-
- let icon_selector: &'static str =
- Box::leak(format!("termua-sessions-session-icon-nushell-{session_id}").into_boxed_str());
+ Box::leak(format!("termua-sessions-session-icon-local-{session_id}").into_boxed_str());
cx.debug_bounds(icon_selector)
- .expect("expected Nushell sessions to render nushell.png as their icon");
+ .expect("expected local sessions to render the generic terminal icon");
}
#[gpui::test]
@@ -814,12 +726,7 @@ fn blank_area_right_click_shows_new_session_menu_item(cx: &mut gpui::TestAppCont
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-blank-new-session-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-blank-new-session");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let (root, cx) = cx.add_window_view(|window, cx| {
@@ -872,19 +779,13 @@ fn folder_right_click_shows_new_session_menu_item(cx: &mut gpui::TestAppContext)
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-folder-new-session-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-folder-new-session");
let _guard = crate::store::tests::override_termua_db_path(db_path);
crate::store::save_local_session(
"Group",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -948,23 +849,13 @@ fn sidebar_shows_load_error_when_disk_sessions_cannot_be_parsed(cx: &mut gpui::T
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-load-error-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-load-error");
let _guard = crate::store::tests::override_termua_db_path(db_path.clone());
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -1002,19 +893,13 @@ fn session_labels_do_not_wrap_when_sidebar_is_narrow(cx: &mut gpui::TestAppConte
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-nowrap-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-nowrap");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"Group",
"This is a very long session name that should not wrap",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -1071,23 +956,13 @@ fn reload_coalesces_while_previous_reload_is_in_flight(cx: &mut gpui::TestAppCon
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-reload-coalesce-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-reload-coalesce");
let _guard = crate::store::tests::override_termua_db_path(db_path);
crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
@@ -1168,23 +1043,13 @@ fn repeated_delete_requests_for_same_session_are_ignored(cx: &mut gpui::TestAppC
gpui_component::init(app);
});
- let tmp_dir = std::env::temp_dir().join(format!(
- "termua-sessions-sidebar-delete-dedupe-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ));
- std::fs::create_dir_all(&tmp_dir).unwrap();
- let db_path = tmp_dir.join("termua").join("termua.db");
+ let db_path = crate::store::tests::unique_test_db_path("sessions-sidebar-delete-dedupe");
let _guard = crate::store::tests::override_termua_db_path(db_path);
let session_id = crate::store::save_local_session(
"local",
"bash",
crate::settings::TerminalBackend::Wezterm,
- "bash",
"xterm-256color",
"UTF-8",
)
diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs
index 270ff68..19de97d 100644
--- a/termua/src/panel/terminal_panel.rs
+++ b/termua/src/panel/terminal_panel.rs
@@ -620,29 +620,6 @@ mod tests {
);
}
- #[test]
- fn local_tabs_use_shell_display_name_when_present() {
- let mut env = HashMap::new();
- env.insert("TERMUA_SHELL".into(), "/bin/bash".into());
- let mut counts = HashMap::new();
- assert_eq!(
- local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(),
- "bash"
- );
-
- env.insert("TERMUA_SHELL".into(), "nu".into());
- assert_eq!(
- local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(),
- "nushell"
- );
-
- env.insert("TERMUA_SHELL".into(), "pwsh".into());
- assert_eq!(
- local_terminal_panel_tab_name(&env, 7, &mut counts).as_ref(),
- "powershell"
- );
- }
-
#[test]
fn local_tabs_fall_back_to_local_prefix_without_shell() {
let mut counts = HashMap::new();
diff --git a/termua/src/session/store.rs b/termua/src/session/store.rs
index 280ca25..a860763 100644
--- a/termua/src/session/store.rs
+++ b/termua/src/session/store.rs
@@ -146,9 +146,6 @@ pub struct Session {
pub backend: TerminalBackend,
pub env: Option>,
- // Local
- pub shell_program: Option,
-
// SSH
pub ssh_host: Option,
pub ssh_port: Option,
@@ -287,7 +284,6 @@ fn init_schema(conn: &Connection) -> anyhow::Result<()> {
label TEXT NOT NULL,
backend TEXT NOT NULL,
session_env TEXT,
- shell_program TEXT,
ssh_host TEXT,
ssh_port INTEGER,
ssh_auth_type TEXT,
@@ -520,7 +516,6 @@ struct SessionWrite<'a> {
label: &'a str,
backend: TerminalBackend,
env: Vec,
- shell_program: Option<&'a str>,
ssh_host: Option<&'a str>,
ssh_port: Option,
ssh_auth_type: Option,
@@ -546,7 +541,6 @@ impl<'a> SessionWrite<'a> {
group_path: &'a str,
label: &'a str,
backend: TerminalBackend,
- shell_program: &'a str,
term: &'a str,
colorterm: Option<&'a str>,
charset: &'a str,
@@ -558,7 +552,6 @@ impl<'a> SessionWrite<'a> {
label,
backend,
env: merge_terminal_fields_into_env(term, colorterm, charset, env),
- shell_program: Some(shell_program),
ssh_host: None,
ssh_port: None,
ssh_auth_type: None,
@@ -607,7 +600,6 @@ impl<'a> SessionWrite<'a> {
label,
backend,
env: merge_terminal_fields_into_env(term, colorterm, charset, env),
- shell_program: None,
ssh_host: Some(host),
ssh_port: Some(port),
ssh_auth_type: Some(SshAuthType::Password),
@@ -654,7 +646,6 @@ impl<'a> SessionWrite<'a> {
label,
backend,
env: merge_terminal_fields_into_env(term, colorterm, charset, env),
- shell_program: None,
ssh_host: Some(host),
ssh_port: Some(port),
ssh_auth_type: Some(SshAuthType::Config),
@@ -696,7 +687,6 @@ impl<'a> SessionWrite<'a> {
label,
backend,
env: merge_terminal_fields_into_env(term, None, charset, Vec::new()),
- shell_program: None,
ssh_host: None,
ssh_port: None,
ssh_auth_type: None,
@@ -760,12 +750,11 @@ fn insert_session_row(conn: &Connection, session: &SessionWrite<'_>) -> anyhow::
INSERT INTO sessions (
protocol, group_path, label,
backend, session_env,
- shell_program,
ssh_host, ssh_port, ssh_auth_type, ssh_user, ssh_credential_username,
ssh_tcp_nodelay, ssh_tcp_keepalive,
ssh_proxy_mode, ssh_proxy_command, ssh_proxy_workdir, ssh_proxy_env, ssh_proxy_jump,
serial_port, serial_baud, serial_data_bits, serial_parity, serial_stop_bits, serial_flow_control
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)
"#,
params![
protocol_to_str(&session.protocol),
@@ -773,7 +762,6 @@ fn insert_session_row(conn: &Connection, session: &SessionWrite<'_>) -> anyhow::
session.label,
backend_to_str(session.backend),
session_env_json,
- session.shell_program,
session.ssh_host,
session.ssh_port.map(|value| value as i64),
session.ssh_auth_type.as_ref().map(ssh_auth_to_str),
@@ -815,25 +803,24 @@ fn update_session_row(
label = ?4,
backend = ?5,
session_env = ?6,
- shell_program = ?7,
- ssh_host = ?8,
- ssh_port = ?9,
- ssh_auth_type = ?10,
- ssh_user = ?11,
+ ssh_host = ?7,
+ ssh_port = ?8,
+ ssh_auth_type = ?9,
+ ssh_user = ?10,
ssh_credential_username = NULL,
- ssh_tcp_nodelay = ?12,
- ssh_tcp_keepalive = ?13,
- ssh_proxy_mode = ?14,
- ssh_proxy_command = ?15,
- ssh_proxy_workdir = ?16,
- ssh_proxy_env = ?17,
- ssh_proxy_jump = ?18,
- serial_port = ?19,
- serial_baud = ?20,
- serial_data_bits = ?21,
- serial_parity = ?22,
- serial_stop_bits = ?23,
- serial_flow_control = ?24,
+ ssh_tcp_nodelay = ?11,
+ ssh_tcp_keepalive = ?12,
+ ssh_proxy_mode = ?13,
+ ssh_proxy_command = ?14,
+ ssh_proxy_workdir = ?15,
+ ssh_proxy_env = ?16,
+ ssh_proxy_jump = ?17,
+ serial_port = ?18,
+ serial_baud = ?19,
+ serial_data_bits = ?20,
+ serial_parity = ?21,
+ serial_stop_bits = ?22,
+ serial_flow_control = ?23,
updated_at = unixepoch()
WHERE id = ?1
"#,
@@ -844,7 +831,6 @@ fn update_session_row(
session.label,
backend_to_str(session.backend),
session_env_json,
- session.shell_program,
session.ssh_host,
session.ssh_port.map(|value| value as i64),
session.ssh_auth_type.as_ref().map(ssh_auth_to_str),
@@ -889,27 +875,16 @@ pub fn save_local_session(
group_path: &str,
label: &str,
backend: TerminalBackend,
- shell_program: &str,
term: &str,
charset: &str,
) -> anyhow::Result {
- save_local_session_with_env(
- group_path,
- label,
- backend,
- shell_program,
- term,
- None,
- charset,
- Vec::new(),
- )
+ save_local_session_with_env(group_path, label, backend, term, None, charset, Vec::new())
}
pub fn save_local_session_with_env(
group_path: &str,
label: &str,
backend: TerminalBackend,
- shell_program: &str,
term: &str,
colorterm: Option<&str>,
charset: &str,
@@ -918,16 +893,7 @@ pub fn save_local_session_with_env(
let conn = open()?;
insert_session_row(
&conn,
- &SessionWrite::local(
- group_path,
- label,
- backend,
- shell_program,
- term,
- colorterm,
- charset,
- env,
- ),
+ &SessionWrite::local(group_path, label, backend, term, colorterm, charset, env),
)
.context("insert local session")
}
@@ -1198,7 +1164,6 @@ pub fn load_all_sessions() -> anyhow::Result> {
SELECT
id, protocol, group_path, label,
backend, session_env,
- shell_program,
ssh_host, ssh_port, ssh_auth_type, ssh_user, ssh_credential_username,
ssh_tcp_nodelay, ssh_tcp_keepalive,
ssh_proxy_mode, ssh_proxy_command, ssh_proxy_workdir, ssh_proxy_env, ssh_proxy_jump,
@@ -1227,7 +1192,6 @@ pub fn load_session(id: i64) -> anyhow::Result