//! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the
//! conversation, per thread, started in the thread's folder (its worktree
//! when it has one), on the device the thread runs on. Ctrl+J shows and
//! hides it; its top edge drags. One
//! terminal fills it; splits lay a group out side by side or stacked; with
//! more than one terminal a sidebar lists the groups. A terminal whose shell
//! exits closes, and closing the last one closes the drawer.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use gpui_kit::component::{Icon, h_flex, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::surfaces::{Surface, TERMINAL_PANEL_WIDTH};
use super::{Workspace, actions};
use crate::remote::client::ConnectionStatus;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, Palette};
use crate::ui::terminal::{TerminalEvent, TerminalView};
/// T3's DEFAULT_THREAD_TERMINAL_HEIGHT.
pub(super) const DEFAULT_HEIGHT: f32 = 280.;
const MIN_HEIGHT: f32 = 180.;
/// The drawer takes at most this much of the window's height.
const MAX_HEIGHT_RATIO: f32 = 0.75;
/// T3's MAX_TERMINALS_PER_GROUP.
const MAX_TERMINALS_PER_GROUP: usize = 4;
/// The grab area along the drawer's top edge.
const HANDLE_HEIGHT: f32 = 6.;
/// The terminal sidebar's width (T3's `w-36`).
const SIDEBAR_WIDTH: f32 = 144.;
/// A little longer than a confirmation dialog takes to close.
const CONFIRM_FADE: Duration = Duration::from_millis(320);
/// The drawer's height, kept between its minimum and three quarters of
/// the window.
pub(super) fn clamp_height(height: f32, window_height: f32) -> f32 {
let max = MIN_HEIGHT.max((window_height * MAX_HEIGHT_RATIO).floor());
let height = if height.is_finite() { height } else { DEFAULT_HEIGHT };
height.round().clamp(MIN_HEIGHT, max)
}
/// `Terminal 3`, as T3 labels `term-3`.
pub(super) fn label(id: u32) -> String {
format!("Terminal {id}")
}
/// Terminals sharing the drawer at once.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct TerminalGroup {
pub terminals: Vec<u32>,
/// Stacked, rather than side by side.
pub vertical: bool,
}
/// A thread's terminals and their groups, as T3's terminal UI store keeps
/// them. Terminals are numbered from 1, reusing the lowest free number.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct TerminalLayout {
/// In the order they were opened.
pub terminals: Vec<u32>,
pub groups: Vec<TerminalGroup>,
pub active: Option<u32>,
}
impl TerminalLayout {
/// T3's nextTerminalId: the lowest number not in use.
fn next_id(&self) -> u32 {
(1..).find(|id| !self.terminals.contains(id)).unwrap_or(1)
}
pub fn is_empty(&self) -> bool {
self.terminals.is_empty()
}
/// The group showing: the active terminal's.
pub fn active_group(&self) -> Option<&TerminalGroup> {
let active = self.active?;
self.groups.iter().find(|group| group.terminals.contains(&active))
}
/// Whether the showing group can't take another split.
pub fn at_split_limit(&self) -> bool {
self.active_group().is_some_and(|group| group.terminals.len() >= MAX_TERMINALS_PER_GROUP)
}
/// Opens a terminal in a group of its own, and returns its number.
pub fn new_terminal(&mut self) -> u32 {
let id = self.next_id();
self.terminals.push(id);
self.groups.push(TerminalGroup { terminals: vec![id], vertical: false });
self.active = Some(id);
id
}
/// Opens a terminal next to the active one, turning its group the way
/// asked. `None` when the group is full.
pub fn split(&mut self, vertical: bool) -> Option<u32> {
let Some(active) = self.active else { return Some(self.new_terminal()) };
if self.at_split_limit() {
return None;
}
let id = self.next_id();
let group = self.groups.iter_mut().find(|group| group.terminals.contains(&active))?;
let anchor =
group.terminals.iter().position(|terminal| *terminal == active).map_or(group.terminals.len(), |ix| ix + 1);
group.terminals.insert(anchor, id);
group.vertical = vertical;
self.terminals.push(id);
self.active = Some(id);
Some(id)
}
pub fn set_active(&mut self, id: u32) {
if self.terminals.contains(&id) {
self.active = Some(id);
}
}
/// Takes a terminal out; the one after it in opening order (or the new
/// last one) becomes active if it was.
pub fn close(&mut self, id: u32) {
let Some(ix) = self.terminals.iter().position(|terminal| *terminal == id) else { return };
self.terminals.remove(ix);
for group in &mut self.groups {
group.terminals.retain(|terminal| *terminal != id);
}
self.groups.retain(|group| !group.terminals.is_empty());
if self.active == Some(id) {
self.active = self.terminals.get(ix.min(self.terminals.len().saturating_sub(1))).copied();
}
}
}
/// One thread's drawer.
#[derive(Default)]
pub(super) struct TerminalDrawer {
pub open: bool,
pub layout: TerminalLayout,
views: HashMap<u32, (Entity<TerminalView>, Subscription)>,
}
impl TerminalDrawer {
fn view(&self, id: u32) -> Option<&Entity<TerminalView>> {
self.views.get(&id).map(|(view, _)| view)
}
}
impl Workspace {
/// Where the open thread's terminals start: its folder on this device.
/// `None` for threads on other devices, whose shells start there.
fn terminal_cwd(&self, cx: &App) -> Option<PathBuf> {
let session = self.active.as_ref()?.read(cx);
let project = self.active_project()?;
if session.is_remote() || project.environment.is_some() {
return None;
}
Some(session.cwd.clone()).filter(|cwd| cwd.is_dir())
}
/// The open thread runs on another device that's connected now, so its
/// terminals' shells can start there.
fn remote_terminal_available(&self, cx: &App) -> bool {
let Some(session) = self.active.as_ref() else { return false };
let Some(env) = session.read(cx).remote.as_ref().map(|remote| remote.env.clone()) else { return false };
self.remote
.environments
.iter()
.any(|candidate| candidate.host.id == env && matches!(candidate.status, ConnectionStatus::Connected(_)))
}
/// Whether the open thread can have terminals.
pub(super) fn terminal_available(&self, cx: &App) -> bool {
self.terminal_cwd(cx).is_some() || self.remote_terminal_available(cx)
}
/// Whether the open thread's drawer shows.
pub(super) fn terminal_open(&self) -> bool {
self.active.as_ref().and_then(|session| self.terminals.get(&session.entity_id())).is_some_and(|drawer| drawer.open)
}
/// T3's terminal.toggle: shows the drawer (with a first terminal if it
/// has none) and focuses it, or hides it and goes back to the composer.
pub(super) fn toggle_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings_open {
return;
}
// In the right panel, it hides from there.
if self.surface_open(Surface::Terminal) {
self.right_panel = None;
self.focus_active_composer(window, cx);
cx.notify();
return;
}
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
if self.terminal_open() {
if let Some(drawer) = self.terminals.get_mut(&session) {
drawer.open = false;
}
self.focus_active_composer(window, cx);
cx.notify();
return;
}
if !self.terminal_available(cx) {
return;
}
let drawer = self.terminals.entry(session).or_default();
drawer.open = true;
if drawer.layout.is_empty() {
self.new_terminal(window, cx);
} else {
self.focus_terminal(window, cx);
}
cx.notify();
}
/// Shows the open thread's terminals in the right panel instead of the
/// drawer, starting one if it has none.
pub(super) fn dock_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
let drawer = self.terminals.entry(session).or_default();
drawer.open = false;
let empty = drawer.layout.is_empty();
self.right_panel = Some(Surface::Terminal);
if empty {
self.new_terminal(window, cx);
} else {
self.focus_terminal(window, cx);
}
}
/// T3's terminal.new: a terminal in a group of its own.
pub(super) fn new_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
let docked = self.surface_open(Surface::Terminal);
if docked {
self.terminals.entry(session).or_default();
}
let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return };
let id = drawer.layout.new_terminal();
self.start_terminal(session, id, window, cx);
}
/// T3's terminal.split and terminal.splitVertical.
pub(super) fn split_terminal(&mut self, vertical: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
let docked = self.surface_open(Surface::Terminal);
let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return };
let Some(id) = drawer.layout.split(vertical) else { return };
self.start_terminal(session, id, window, cx);
}
/// Starts terminal `id`'s shell, here or on the thread's device, and
/// focuses it.
fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context<Self>) {
let view = match self.terminal_cwd(cx) {
Some(cwd) => Some(cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx))),
None => self
.active
.clone()
.filter(|active| active.entity_id() == session)
.and_then(|active| self.open_remote_terminal(&active, label(id), window, cx)),
};
let Some(view) = view else {
// Nothing to show in the place the layout made for it.
if let Some(drawer) = self.terminals.get_mut(&session) {
drawer.layout.close(id);
}
return;
};
let subscription = cx.subscribe_in(&view, window, move |this, _, event: &TerminalEvent, window, cx| match event {
TerminalEvent::Exited => this.close_terminal(session, id, false, window, cx),
TerminalEvent::AddToChat(text) => {
if let Some(chat) = this.chats.get(&session).cloned() {
chat.update(cx, |chat, cx| chat.insert_into_composer(text, window, cx));
}
}
});
if let Some(drawer) = self.terminals.get_mut(&session) {
drawer.views.insert(id, (view, subscription));
}
self.focus_terminal(window, cx);
cx.notify();
}
/// Hands focus to the open thread's active terminal.
fn focus_terminal(&self, window: &mut Window, cx: &mut App) {
let Some(session) = self.active.as_ref() else { return };
let Some(drawer) = self.terminals.get(&session.entity_id()) else { return };
if let Some(view) = drawer.layout.active.and_then(|id| drawer.view(id)) {
view.read(cx).focus_handle(cx).focus(window, cx);
}
}
fn focus_active_composer(&self, window: &mut Window, cx: &mut App) {
if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())).cloned() {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
}
/// The open thread's active terminal.
fn active_terminal(&self) -> Option<u32> {
let session = self.active.as_ref()?;
self.terminals.get(&session.entity_id())?.layout.active
}
pub(super) fn set_active_terminal(&mut self, id: u32, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
if let Some(drawer) = self.terminals.get_mut(&session) {
drawer.layout.set_active(id);
}
self.focus_terminal(window, cx);
cx.notify();
}
/// T3's terminal.close: asks first, as its confirmTerminalClose does.
pub(super) fn request_close_terminal(&mut self, id: Option<u32>, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
let Some(drawer) = self.terminals.get(&session).filter(|drawer| drawer.open) else { return };
let Some(id) = id.or(drawer.layout.active) else { return };
let message = format!("Close terminal \"{}\"?\nThis stops the running process and clears its history.", label(id));
let workspace = cx.weak_entity();
window.defer(cx, move |window, cx| {
actions::confirm(&message, true, window, cx, move |window, cx| {
let _ = workspace.update(cx, |this, cx| this.close_terminal(session, id, true, window, cx));
})
});
}
/// Stops a terminal's shell and takes it out of the drawer, which closes
/// with its last terminal.
fn close_terminal(&mut self, session: EntityId, id: u32, confirmed: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(drawer) = self.terminals.get_mut(&session) else { return };
drawer.views.remove(&id);
drawer.layout.close(id);
let empty = drawer.layout.is_empty();
if empty {
self.terminals.remove(&session);
if self.surface_open(Surface::Terminal) && self.active.as_ref().is_some_and(|active| active.entity_id() == session) {
self.right_panel = None;
}
}
if self.active.as_ref().is_some_and(|active| active.entity_id() == session) {
let refocus = move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
if this.active.as_ref().is_some_and(|active| active.entity_id() == session) {
if empty {
this.focus_active_composer(window, cx);
} else {
this.focus_terminal(window, cx);
}
}
};
refocus(self, window, cx);
if confirmed {
// The confirmation hands focus back to the closed terminal as
// its dialog fades out; take it once that's done.
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(CONFIRM_FADE).await;
let _ = this.update_in(cx, |this, window, cx| refocus(this, window, cx));
})
.detach();
}
}
cx.notify();
}
/// Forgets a thread's terminals, stopping their shells.
pub(super) fn drop_terminals(&mut self, session: EntityId) {
self.terminals.remove(&session);
}
/// Dragging the drawer's top edge.
pub(super) fn drag_terminal_edge(&mut self, event: &MouseMoveEvent, window: &mut Window, cx: &mut Context<Self>) {
let Some((start_y, start_height)) = self.terminal_drag else { return };
let height = start_height + (start_y - event.position.y).as_f32();
self.terminal_height = clamp_height(height, window.viewport_size().height.as_f32());
cx.notify();
}
pub(super) fn end_terminal_drag(&mut self, cx: &mut Context<Self>) {
self.terminal_drag = None;
cx.notify();
}
/// The open thread's drawer, when it shows.
/// The open thread's terminals: in the drawer under the conversation,
/// or `docked` in the right panel.
pub(super) fn render_terminal_drawer(&self, docked: bool, window: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> {
let session = self.active.as_ref()?.entity_id();
let no_terminals = TerminalDrawer::default();
let drawer = match self.terminals.get(&session) {
Some(drawer) if docked || drawer.open => drawer,
None if docked => &no_terminals,
_ => return None,
};
if !docked && self.surface_open(Surface::Terminal) {
return None;
}
let p = metal::palette(cx);
let height = clamp_height(self.terminal_height, window.viewport_size().height.as_f32());
let layout = &drawer.layout;
let handle = div()
.id("terminal-drawer-edge")
.absolute()
.top_0()
.left_0()
.right_0()
.h(px(HANDLE_HEIGHT))
.cursor_row_resize()
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
this.terminal_drag = Some((event.position.y, height));
cx.notify();
}),
);
let base = v_flex().relative().overflow_hidden().map(|this| {
if docked {
this.size_full().rounded(px(18.)).border_1().border_color(p.rule)
} else {
this.flex_none().w_full().h(px(height)).child(metal::rule(p))
}
});
// Docked, it fills the panel and has no edge to drag.
let handle = (!docked).then_some(handle);
let finish = move |body: AnyElement| -> AnyElement {
if docked {
div().w(px(TERMINAL_PANEL_WIDTH)).flex_none().h_full().p(px(12.)).pl(px(0.)).child(body).into_any_element()
} else {
body
}
};
if layout.is_empty() {
return Some(finish(
base.children(handle)
.child(
v_flex()
.flex_1()
.min_h_0()
.items_center()
.justify_center()
.gap(px(12.))
.px(px(16.))
.py(px(24.))
.text_size(px(14.))
.text_color(p.ink3)
.child("No terminal sessions for this thread yet.")
.child(
MetalButton::new("terminal-empty-new")
.compact()
.label(NEW_TERMINAL_LABEL)
.on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx))),
),
)
.into_any_element(),
));
}
let active = layout.active;
let visible: Vec<u32> = layout.active_group().map(|group| group.terminals.clone()).unwrap_or_default();
let vertical = layout.active_group().is_some_and(|group| group.vertical);
let has_sidebar = layout.terminals.len() > 1;
let at_limit = layout.at_split_limit();
let panes = visible.iter().enumerate().filter_map(|(ix, id)| {
let view = drawer.view(*id)?.clone();
let id = *id;
let border = if Some(id) == active { p.rule } else { p.rule.opacity(0.7) };
Some(
div()
.id(("terminal-pane", id as usize))
.flex_1()
.min_w_0()
.min_h_0()
.when(ix > 0 && vertical, |this| this.border_t_1().border_color(border))
.when(ix > 0 && !vertical, |this| this.border_l_1().border_color(border))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, window, cx| {
if this.active_terminal() != Some(id) {
this.set_active_terminal(id, window, cx);
}
}),
)
.child(view),
)
});
let grid = if vertical {
v_flex().size_full().children(panes).into_any_element()
} else {
h_flex().size_full().items_stretch().children(panes).into_any_element()
};
let body = h_flex()
.flex_1()
.min_h_0()
.w_full()
.items_stretch()
.when(has_sidebar, |this| this.gap(px(6.)))
.child(div().flex_1().min_w_0().h_full().child(grid))
.when(has_sidebar, |this| this.child(self.render_terminal_sidebar(drawer, at_limit, cx)));
Some(finish(
base.children(handle)
.child(body)
.when(!has_sidebar, |this| {
this.child(
div()
.absolute()
.top(px(8.))
.right(px(8.))
.child(
h_flex()
.items_center()
.rounded(px(8.))
.border_1()
.border_color(p.rule)
.bg(p.menu_bg)
.shadow(p.raise_sm.clone())
.children(terminal_actions("terminal-actions", at_limit, cx).into_iter().enumerate().flat_map(
|(ix, button)| {
let separator = (ix > 0).then(|| metal::vrule(p).into_any_element());
separator.into_iter().chain(Some(button))
},
)),
),
)
})
.into_any_element(),
))
}
/// The list of groups beside the terminals once there's more than one.
fn render_terminal_sidebar(&self, drawer: &TerminalDrawer, at_limit: bool, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let layout = &drawer.layout;
let active = layout.active;
let show_group_headers = layout.groups.len() > 1 || layout.groups.iter().any(|group| group.terminals.len() > 1);
v_flex()
.flex_none()
.w(px(SIDEBAR_WIDTH))
.h_full()
.border_1()
.border_color(p.rule)
.bg(p.ink.opacity(0.02))
.child(
h_flex()
.flex_none()
.h(px(22.))
.justify_end()
.border_b_1()
.border_color(p.rule)
.children(terminal_actions("terminal-sidebar-actions", at_limit, cx)),
)
.child(
v_flex()
.id("terminal-groups")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.p(px(4.))
.children(layout.groups.iter().enumerate().map(|(group_ix, group)| {
let rows: Vec<AnyElement> = group
.terminals
.iter()
.map(|id| terminal_row(*id, active == Some(*id), p, cx).into_any_element())
.collect();
let group_active = active.is_some_and(|id| group.terminals.contains(&id));
let focus_id = if group_active { active } else { group.terminals.first().copied() };
let split = group.terminals.len() > 1;
let (icon, name) = match (split, group.vertical) {
(false, _) => (Lucide::Square, "Single"),
(true, true) => (Lucide::SquareSplitVertical, "Stacked"),
(true, false) => (Lucide::SquareSplitHorizontal, "Side by side"),
};
v_flex()
.pb(px(2.))
.when(show_group_headers, |this| {
this.child(
h_flex()
.id(("terminal-group", group_ix))
.h(px(22.))
.gap(px(4.))
.px(px(6.))
.rounded(px(4.))
.cursor_pointer()
.text_size(px(11.))
.map(|this| {
if group_active {
this.bg(p.row_hover).text_color(p.ink)
} else {
this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink))
}
})
.on_click(cx.listener(move |this, _, window, cx| {
if let Some(id) = focus_id {
this.set_active_terminal(id, window, cx);
}
}))
.child(Icon::new(icon).size(px(12.)).flex_none())
.child(div().flex_1().min_w_0().truncate().child(name))
.child(div().text_size(px(10.)).text_color(p.ink3.opacity(0.7)).child(group.terminals.len().to_string())),
)
})
.child(v_flex().gap(px(2.)).children(rows))
})),
)
}
}
/// T3's newTerminalActionLabel, with the shortcut a focused terminal takes.
const NEW_TERMINAL_LABEL: &str = "New Terminal (Ctrl+N)";
/// The split, new and close buttons, as the floating group and the sidebar
/// header show them.
fn terminal_actions(id: &'static str, at_limit: bool, cx: &mut Context<Workspace>) -> Vec<AnyElement> {
let split_label = |direction: &str, shortcut: &str| -> SharedString {
if at_limit {
format!("Split Terminal {direction} (max {MAX_TERMINALS_PER_GROUP} per group)").into()
} else {
format!("Split Terminal {direction} ({shortcut})").into()
}
};
let button = |name: &'static str, icon: Lucide, tooltip: SharedString| {
MetalButton::new(SharedString::from(format!("{id}-{name}")))
.ghost()
.mini()
.icon(icon)
.rounded(px(6.))
.tooltip(tooltip)
};
vec![
button("split", Lucide::SquareSplitHorizontal, split_label("Horizontally", "Ctrl+D"))
.disabled(at_limit)
.on_click(cx.listener(|this, _, window, cx| this.split_terminal(false, window, cx)))
.into_any_element(),
button("split-vertical", Lucide::SquareSplitVertical, split_label("Vertically", "Ctrl+Shift+D"))
.disabled(at_limit)
.on_click(cx.listener(|this, _, window, cx| this.split_terminal(true, window, cx)))
.into_any_element(),
button("new", Lucide::Plus, NEW_TERMINAL_LABEL.into())
.on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx)))
.into_any_element(),
button("close", Lucide::Trash, "Close Terminal (Ctrl+W)".into())
.on_click(cx.listener(|this, _, window, cx| this.request_close_terminal(None, window, cx)))
.into_any_element(),
]
}
/// A terminal in the sidebar: its icon, which turns into a close button on
/// hover, and its label.
fn terminal_row(id: u32, active: bool, p: &Palette, cx: &mut Context<Workspace>) -> impl IntoElement {
let name = label(id);
let close = SharedString::from(format!("Close {name}{}", if active { " (Ctrl+W)" } else { "" }));
let group = SharedString::from(format!("terminal-row-{id}"));
h_flex()
.id(("terminal-row", id as usize))
.group(group.clone())
.h(px(24.))
.gap(px(2.))
.pl(px(6.))
.pr(px(8.))
.rounded(px(6.))
.cursor_pointer()
.text_size(px(12.))
.map(|this| {
if active {
this.bg(metal::vertical(p.row_on)).text_color(p.ink)
} else {
this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink))
}
})
.on_click(cx.listener(move |this, _, window, cx| this.set_active_terminal(id, window, cx)))
.child(
div()
.id(("terminal-row-close", id as usize))
.relative()
.flex_none()
.size(px(16.))
.flex()
.items_center()
.justify_center()
.rounded(px(4.))
.tooltip(move |window, cx| Tooltip::new(close.clone()).build(window, cx))
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.request_close_terminal(Some(id), window, cx);
}))
.child(
div()
.group_hover(group.clone(), |style| style.invisible())
.child(Icon::new(Lucide::SquareTerminal).size(px(12.))),
)
.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.invisible()
.group_hover(group, |style| style.visible())
.child(Icon::new(Lucide::X).size(px(12.))),
),
)
.child(div().flex_1().min_w_0().truncate().child(name))
}
#[cfg(test)]
mod tests {
use super::{MAX_TERMINALS_PER_GROUP, TerminalLayout, clamp_height, label};
#[test]
fn labels_number_from_one_and_reuse_gaps() {
let mut layout = TerminalLayout::default();
assert_eq!(layout.new_terminal(), 1);
assert_eq!(layout.new_terminal(), 2);
assert_eq!(layout.new_terminal(), 3);
layout.close(2);
assert_eq!(layout.new_terminal(), 2);
assert_eq!(label(2), "Terminal 2");
assert_eq!(layout.terminals, vec![1, 3, 2]);
}
#[test]
fn splits_join_the_active_group() {
let mut layout = TerminalLayout::default();
layout.new_terminal();
assert_eq!(layout.split(false), Some(2));
assert_eq!(layout.groups.len(), 1);
assert_eq!(layout.groups[0].terminals, vec![1, 2]);
layout.set_active(1);
assert_eq!(layout.split(true), Some(3));
// After the terminal it split from, and the group turns stacked.
assert_eq!(layout.groups[0].terminals, vec![1, 3, 2]);
assert!(layout.groups[0].vertical);
assert_eq!(layout.split(false), Some(4));
assert_eq!(layout.groups[0].terminals.len(), MAX_TERMINALS_PER_GROUP);
assert_eq!(layout.split(false), None);
assert!(layout.at_split_limit());
}
#[test]
fn closing_moves_on_and_empties() {
let mut layout = TerminalLayout::default();
layout.new_terminal();
layout.new_terminal();
layout.new_terminal();
layout.set_active(2);
layout.close(2);
assert_eq!(layout.active, Some(3));
assert_eq!(layout.groups.len(), 2);
layout.close(3);
assert_eq!(layout.active, Some(1));
layout.close(1);
assert!(layout.is_empty());
assert_eq!(layout.active, None);
}
#[test]
fn heights_clamp_to_the_window() {
assert_eq!(clamp_height(100., 900.), 180.);
assert_eq!(clamp_height(300.4, 900.), 300.);
assert_eq!(clamp_height(800., 900.), 675.);
assert_eq!(clamp_height(400., 200.), 180.);
assert_eq!(clamp_height(f32::NAN, 900.), 280.);
}
}