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