Files
tty7/src/ui/app.rs
T
l0ng-ai 962fd87ea5 feat(tabs): step to the next/previous tab without the switcher (#867)
Add SelectNextTab / SelectPrevTab, which move to the neighbouring tab in
the order the strip or sidebar shows them, wrapping, with no popup.
Defaults: Cmd+Shift+] / Cmd+Shift+[ on macOS, Ctrl+PgDn / Ctrl+PgUp
elsewhere. The tmux preset's prefix n / p now bind these (tmux
next-window semantics) instead of the MRU switcher, which never
auto-committed there.

NextTab / PrevTab keep their config names and Ctrl+Tab, but are labelled
Recent Tab Switcher on the Keybindings page; the palette's Next/Previous
Tab entries now show the SelectNextTab chord they actually run.
2026-09-23 15:20:34 +08:00

12360 lines
489 KiB
Rust

use gpui::{
App, Axis, Bounds, Context, Edges, Entity, Focusable, Pixels, PromptLevel, Size, Subscription,
Window, div, img, point, prelude::*, px, size,
};
use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
use gpui_component::input::{InputEvent, InputState};
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
use gpui_component::slider::{SliderEvent, SliderState};
use gpui_component::{
ActiveTheme as _, IndexPath, InteractiveElementExt as _, TitleBar, WindowExt as _,
};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use crate::core::actions::*;
use crate::core::config::{
Config, CursorStyle as ConfigCursorStyle, MouseZoomModifier, NewTabPosition, RightPanelTab,
ShellConfig, TabBarPosition, WindowBackdrop,
};
use crate::core::session::{
Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore,
};
use crate::core::shells::ShellInventory;
use crate::core::ssh_config;
use crate::core::window_state::{WindowGeometry as _, WindowState};
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
use crate::daemon::spawn::DaemonMismatch;
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::forwards::{ForwardFields, added_forward, rule_of};
use crate::ui::host_registry::HostId;
use crate::ui::i18n::{L10nKey, set_locale, t, t_fmt, t_plural};
use crate::ui::palette::{
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
};
use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot};
use crate::ui::presets::Fill;
use crate::ui::scm::ScmIntent;
use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor};
use crate::ui::theme::{apply_theme, set_menus};
/// What to start in a pane that is about to be opened.
pub(crate) enum SpawnAs {
/// A local shell; `None` is whatever the default one is.
Shell(Option<ShellSpec>),
Ssh(Box<crate::daemon::protocol::NativeSshSpec>),
}
/// Where a row taken out of the new-tab menu lands.
///
/// Windows Terminal's rule, and the reason this is a parameter rather than two
/// menus: the same list of shells and hosts serves both, and ⌥ at click time
/// picks between them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SpawnWhere {
NewTab,
/// Beside the pane in front of the user, on [`Axis::Horizontal`] — the
/// side `SplitRight` already puts a pane on. Deliberately not Windows
/// Terminal's aspect-ratio guess: a split that lands somewhere different
/// depending on the shape of the pane cannot be aimed.
Split,
}
impl SpawnWhere {
/// Read off the live keyboard state at click time. The menu hands its
/// handler a synthetic click with no modifiers on it, so there is nowhere
/// else the ⌥ the user is holding can be found.
pub(crate) fn from_modifiers(mods: gpui::Modifiers) -> Self {
if mods.alt {
SpawnWhere::Split
} else {
SpawnWhere::NewTab
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeEdit {
Background,
Foreground,
Accent,
Cursor,
Selection,
Ansi(usize),
}
/// The 16 ANSI slots in the order a terminal numbers them: 0-7, then their
/// bright twins. A theme author needs to know that slot 9 is what `\e[91m`
/// paints — "Color 9" does not say that, and "Bright red" does.
const ANSI_COLOR_LABELS: [L10nKey; 16] = [
L10nKey::AppThemeAnsiBlack,
L10nKey::AppThemeAnsiRed,
L10nKey::AppThemeAnsiGreen,
L10nKey::AppThemeAnsiYellow,
L10nKey::AppThemeAnsiBlue,
L10nKey::AppThemeAnsiMagenta,
L10nKey::AppThemeAnsiCyan,
L10nKey::AppThemeAnsiWhite,
L10nKey::AppThemeAnsiBrightBlack,
L10nKey::AppThemeAnsiBrightRed,
L10nKey::AppThemeAnsiBrightGreen,
L10nKey::AppThemeAnsiBrightYellow,
L10nKey::AppThemeAnsiBrightBlue,
L10nKey::AppThemeAnsiBrightMagenta,
L10nKey::AppThemeAnsiBrightCyan,
L10nKey::AppThemeAnsiBrightWhite,
];
/// Looked up at render time, never stored: the editor outlives a language
/// change, and a label cached when it opened would stay in the old language.
pub(crate) fn theme_edit_label(edit: ThemeEdit) -> &'static str {
t(match edit {
ThemeEdit::Background => L10nKey::AppThemeColorBackground,
ThemeEdit::Foreground => L10nKey::AppThemeColorForeground,
ThemeEdit::Accent => L10nKey::AppThemeColorAccent,
ThemeEdit::Cursor => L10nKey::AppThemeColorCursor,
ThemeEdit::Selection => L10nKey::AppThemeColorSelection,
ThemeEdit::Ansi(i) => ANSI_COLOR_LABELS[i.min(15)],
})
}
fn hsla_to_u32(color: gpui::Hsla) -> u32 {
let rgba: gpui::Rgba = color.into();
let to = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u32;
(to(rgba.r) << 16) | (to(rgba.g) << 8) | to(rgba.b)
}
// The steppers clamp to the same range `sanitize` allows, defined in
// tty7-core next to the validation: a local, narrower pair used to push a
// config-legal value the wrong way — `font_size: 50` shrank to 48 on "+",
// and the result was written back to the file (#550).
pub(crate) use crate::core::config::{
FONT_SIZE_MAX, FONT_SIZE_MIN, LINE_HEIGHT_MAX, LINE_HEIGHT_MIN,
};
pub(crate) const FONT_SIZE_STEP: f32 = 1.0;
pub(crate) const UI_FONT_SIZE_STEP: f32 = 1.0;
pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05;
const MAX_CLOSED_TABS: usize = 20;
const RESIZE_STEP: f32 = 0.05;
pub(crate) const RECORD_COMMIT_DELAY_MS: u64 = 650;
/// The narrowest the terminal column may be squeezed to by the panels beside
/// it: roughly forty columns at the default font, which is a prompt with room
/// to read what it printed.
pub(crate) const TERMINAL_MIN_W: f32 = 360.;
/// Half the period of the home page's cursor, near enough the terminal's own
/// 530ms that the two do not read as different clocks.
pub(crate) const HOME_CURSOR_BLINK_MS: u64 = 600;
/// How wide one side panel may grow, given the floor under the terminal and the
/// floor under the *other* panel.
///
/// Each panel used to cap itself at half the window and know nothing about the
/// other, so the two of them could take the whole of it between them — two
/// halves leave nothing. On a 720-point window with both open the terminal was
/// left about 260 points, twenty-odd columns of the one pane the window exists
/// to show, and dragging either panel wider took the difference out of it.
/// Reserving the terminal's floor and the other panel's floor first means the
/// pane the user came for is spoken for before either panel is, and the cap
/// that comes out of it bounds the drag and the layout alike — so a panel
/// dragged to its limit stays where it was dropped instead of springing back.
///
/// The half-window cap stays, and this takes whichever of the two binds harder.
/// It is the one that binds on a wide window, where the reservation alone would
/// have *raised* the ceiling — on a 1440-point window it works out to 864, so
/// dropping it would have let a panel grow past where it could before under
/// cover of a change that is only meant to take width away from panels.
///
/// `others_floor` is the sum of the floors under every *other* column that is
/// open — the panel opposite, and the document column when a file or a diff is
/// docked. It is zero for each of them that is closed, which is why this takes
/// floors rather than reading them: only the caller knows what is up.
pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, others_floor: f32) -> f32 {
(viewport - TERMINAL_MIN_W - others_floor)
.min(viewport * SIDE_PANEL_MAX_RATIO)
.max(own_floor)
}
/// The half of the window neither panel may grow past on its own.
const SIDE_PANEL_MAX_RATIO: f32 = 0.5;
/// The narrowest a docked document column may be squeezed to: the header, a
/// readable run of about thirty columns, and the status bar under them. Below
/// this a file is a ribbon of hyphenated fragments and the column is worth
/// less than the terminal width it costs.
pub(crate) const DOCUMENT_MIN_W: f32 = 280.;
/// How wide the docked document column is, given the width the terminal and the
/// document share — the window less the sidebar and the right panel — and the
/// share of it the user asked for.
///
/// `None` is the narrow-window answer: there is no way to give both the
/// terminal and a document a width worth reading, so the caller falls back to
/// filling the workspace for this frame. That fallback is *derived*, never
/// stored — widening the window docks again on the next frame, and the user's
/// saved `document_layout` is untouched throughout.
///
/// The named two-thirds deliberately runs past the half-window cap the side
/// panels obey. That cap is there so neither *panel* can dominate a wide
/// display; the document is the thing the user is reading, and an increment
/// that silently became a half on every window wider than about 720 points of
/// body would be a lie. The terminal's floor still binds.
pub(crate) fn document_column_px(body: f32, ratio: f32) -> Option<f32> {
if !body.is_finite() || body < TERMINAL_MIN_W + DOCUMENT_MIN_W {
return None;
}
let ratio = if ratio.is_finite() { ratio } else { 0.5 };
Some((body * ratio).clamp(DOCUMENT_MIN_W, body - TERMINAL_MIN_W))
}
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
pub(crate) const TILE_SIZE: f32 = 32.;
pub(crate) const TILE_GLYPH: f32 = 16.;
/// A tile that sits in a body row rather than in chrome: the box shrinks to
/// the minimum hit target, but the glyph keeps the chrome size. An 11px glyph
/// here read as a disabled ornament next to 14px text, and put a second,
/// smaller folder in the same column as the panel's folder tab.
pub(crate) const TILE_SIZE_SM: f32 = 24.;
pub(crate) const TILE_GLYPH_SM: f32 = TILE_GLYPH;
/// The tile that lives *inside* a list row rather than beside one, for the
/// buttons a row reveals on hover.
///
/// A box below [`TILE_SIZE_SM`] because of width: three `TILE_SIZE_SM` squares
/// would eat 72 of the 236px a file name has to live in, where three of these
/// eat 54.
pub(crate) const TILE_SIZE_XS: f32 = 18.;
pub(crate) const TILE_GLYPH_XS: f32 = 11.;
/// Line-only controls share the toolbar icon size.
pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
pub(crate) const TILE_PAD_SM: f32 = (TILE_SIZE_SM - TILE_GLYPH_SM) / 2.;
const DOCS_URL: &str = "https://github.com/l0ng-ai/tty7#readme";
const DISCORD_URL: &str = "https://discord.gg/s3dethqz2V";
const ISSUES_URL: &str = "https://github.com/l0ng-ai/tty7/issues/new";
pub(crate) const CONTENT_INSET: f32 = 12.;
const TILE_EDGE_GAP: f32 = 5.;
pub(crate) fn tile_trailing_inset() -> f32 {
(CONTENT_INSET - TILE_PAD).max(TILE_EDGE_GAP)
}
pub(crate) fn tile_trailing_inset_sm() -> f32 {
(CONTENT_INSET - TILE_PAD_SM).max(TILE_EDGE_GAP)
}
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. };
pub(crate) fn title_bar_hug_offset() -> f32 {
if cfg!(target_os = "macos") {
0.
} else {
tile_trailing_inset() - TITLE_BAR_LEAD
}
}
/// The bounds to remember for reopening this window at the same place.
///
/// Under client-side decorations the window's *outer* rectangle is the whole
/// surface, shadow included, and both Linux backends put the shadow back on
/// their own once the window is up: Wayland's first sized configure adds the
/// inset to whatever geometry was asked for (`compute_outer_size`), and on X11
/// the inset travels as `_GTK_FRAME_EXTENTS`, which a window manager only
/// advertises — and gpui only turns CSD on for — when it honours those extents
/// by keeping the *visible* frame put. Save the outer rectangle either way and
/// it comes back twice the shadow larger every launch; that is the bug Zed
/// fixed in ca9cee85e1 ("linux: Fix non-maximized Zed windows growing larger
/// across sessions", #22301), whose own measurements were taken on X11.
///
/// So save the inner rectangle, exactly as Zed does, on every platform. It is
/// the visible frame, which is what the backends re-inflate back to, and it
/// costs nothing elsewhere: `inner_window_bounds` defaults to `window_bounds`
/// on the `PlatformWindow` trait, and neither the macOS nor the Windows backend
/// nor gpui's `TestWindow` overrides it. On X11 without a compositor the same
/// holds for a different reason — `window_decorations()` answers `Server`, the
/// window border never calls `set_client_inset`, and the insets stay zero.
fn window_bounds_to_remember(window: &Window) -> Bounds<Pixels> {
window.inner_window_bounds().get_bounds()
}
/// The band the tab strip claims for a drop, in the window's outer coordinates.
///
/// The strip is the top of the *content*, and under client-side decorations the
/// content sits inside the frame padding while `viewport` still measures the
/// whole surface, shadow included — so the band starts at the padding and stops
/// at the far edge of the frame, one padding in from each side. Anything else
/// leaves the outermost chips outside the zone that is supposed to contain
/// them. `window_paddings` answers `Edges::all(0)` under server-side
/// decorations, which is every platform but Linux CSD, so off Linux this stays
/// the full-width rectangle from the window's corner it has always been.
fn strip_band(viewport: Size<Pixels>, pad: Edges<Pixels>) -> Bounds<Pixels> {
Bounds {
origin: point(pad.left, pad.top),
size: size(
(viewport.width - pad.left - pad.right).max(px(0.)),
px(TITLE_BAR_HEIGHT),
),
}
}
pub(crate) const WINDOW_MARK_SIZE: f32 = 20.;
pub(crate) fn title_bar_drag(
row: gpui::Stateful<gpui::Div>,
key: &'static str,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> gpui::Stateful<gpui::Div> {
window_move_gesture(row, key, window, cx).on_double_click(|_, window, _| {
if cfg!(target_os = "linux") {
window.zoom_window();
} else {
window.titlebar_double_click();
}
})
}
pub(crate) struct WindowMoveArm {
should_move: bool,
}
pub(crate) fn window_move_gesture(
row: gpui::Stateful<gpui::Div>,
key: &'static str,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> gpui::Stateful<gpui::Div> {
let arm = window.use_keyed_state(key, cx, |_, _| WindowMoveArm { should_move: false });
row.window_control_area(gpui::WindowControlArea::Drag)
.on_mouse_down(
gpui::MouseButton::Left,
window.listener_for(&arm, |arm, _: &gpui::MouseDownEvent, _, _| {
arm.should_move = true;
}),
)
.on_mouse_down_out(
window.listener_for(&arm, |arm, _: &gpui::MouseDownEvent, _, _| {
arm.should_move = false;
}),
)
.on_mouse_up(
gpui::MouseButton::Left,
window.listener_for(&arm, |arm, _: &gpui::MouseUpEvent, _, _| {
arm.should_move = false;
}),
)
.on_mouse_up_out(
gpui::MouseButton::Left,
window.listener_for(&arm, |arm, _: &gpui::MouseUpEvent, _, _| {
arm.should_move = false;
}),
)
.on_mouse_move(
window.listener_for(&arm, |arm, _: &gpui::MouseMoveEvent, window, _| {
if arm.should_move {
arm.should_move = false;
window.start_window_move();
}
}),
)
}
pub(crate) fn window_mark() -> Option<impl IntoElement> {
if cfg!(target_os = "macos") {
return None;
}
static LOGO: std::sync::OnceLock<Arc<gpui::Image>> = std::sync::OnceLock::new();
let logo = LOGO
.get_or_init(|| {
Arc::new(gpui::Image::from_bytes(
gpui::ImageFormat::Png,
include_bytes!("../../assets/logo@256.png").to_vec(),
))
})
.clone();
Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0())
}
pub struct Tab {
pub pane: Pane,
pub name: Option<String>,
last_focused: Option<gpui::EntityId>,
/// The pane zoomed in this tab, stashed here by `activate` while another
/// tab is on screen — zoom is a tab's view state, not the window's, so
/// looking at another tab and coming back must not lose it (#599). `None`
/// while the tab is active: then the zoom lives in `Tty7App::maximized`.
pub(crate) zoomed: Option<Entity<TerminalView>>,
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
pub(crate) sidebar_group: std::cell::RefCell<Option<crate::core::group_key::GroupKey>>,
pub(crate) overlay_top: OverlayTop,
/// Whether this tab's document fills the workspace or docks beside the
/// terminal, once the tab has been told. `None` follows `document_layout`
/// in the config, which is the default a fresh tab starts from and the
/// last explicit choice anyone made.
///
/// Per tab rather than per window because what you are doing differs per
/// tab: reading a long file in one while an agent works in another wants
/// the whole window here and half of it there, and a global switch made
/// each of those flip the other.
pub(crate) document_layout: Option<crate::core::config::DocumentLayout>,
pub(crate) tree_id: std::cell::Cell<tty7_core::core::machine::TabId>,
/// Monotonic stamp of when this tab was last activated, used to order the
/// switcher's tab column most-recently-used first. Zero means never.
pub(crate) last_used: std::cell::Cell<u64>,
/// Where a directional focus move started, keyed by the pane it landed on
/// and the direction that undoes it, so reversing a move comes back here
/// instead of wherever geometry ranks first (#738). Per tab because the
/// panes are.
///
/// Keyed by pane and not by direction alone: one slot per direction is
/// overwritten by the next move the same way, so a walk of two steps left
/// and two back right ends somewhere other than it started — the very drift
/// this is here to stop. A pane remembering its own way in retraces the
/// whole walk.
///
/// A recorded pane only ever breaks a tie between the panes already next to
/// the one focus is leaving, so an entry left over from an older layout — or
/// from before a click moved focus somewhere else entirely — can at worst
/// pick a different neighbour, never a distant one. Entries naming a pane
/// the tab no longer holds are dropped as the next one is written, so a
/// closed pane leaves nothing behind either.
focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum OverlayTop {
#[default]
Code,
Diff,
}
impl Tab {
pub(crate) fn new(pane: Pane) -> Self {
Self {
pane,
name: None,
last_focused: None,
zoomed: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(None),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
}
}
pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self {
Self {
pane,
name: tree.name.clone(),
last_focused: None,
zoomed: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(
tree.sidebar_group
.as_deref()
.and_then(crate::core::group_key::GroupKey::decode),
),
tree_id: std::cell::Cell::new(tree.id),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
}
}
fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> {
match self.last_focused {
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
None => self.pane.first_leaf(),
}
}
/// The pane a move in `dir` out of `at` should return to, if it reverses
/// the move that brought focus to `at`.
fn focus_origin(&self, at: gpui::EntityId, dir: Dir) -> Option<gpui::EntityId> {
self.focus_origin.get(&(at, dir)).copied()
}
/// Remember that a move in `dir` carried focus from `from` to `to`, so the
/// move back out of `to` returns. `live` names the panes the tab holds now:
/// anything the layout has moved on from is forgotten here rather than
/// accumulating for the life of the window.
fn remember_focus_origin(
&mut self,
from: gpui::EntityId,
to: gpui::EntityId,
dir: Dir,
live: &[gpui::EntityId],
) {
self.focus_origin
.retain(|(at, _), origin| live.contains(at) && live.contains(origin));
self.focus_origin.insert((to, dir.opposite()), from);
}
pub(crate) fn detail_pane(
&self,
window: &Window,
cx: &gpui::App,
) -> Option<Entity<TerminalView>> {
self.pane
.focused_leaf(window, cx)
.or_else(|| self.focus_target())
.and_then(|slot| slot.terminal().cloned())
}
/// The leaf a tab names itself after — its title and, with it, the home
/// that title's path is measured against.
fn title_leaf(&self, window: Option<&Window>, cx: &App) -> Option<Entity<TerminalView>> {
let leaf = match window {
Some(window) => self
.pane
.focused_leaf(window, cx)
.or_else(|| self.focus_target()),
None => self.focus_target(),
};
leaf.and_then(|l| l.terminal().cloned())
}
pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String {
self.title_leaf(window, cx)
.map(|l| l.read(cx).title.clone())
.unwrap_or_default()
}
/// [`Self::leaf_title`] together with what a `~` in it would mean — one
/// leaf lookup, so the title and the home shortening it can never come
/// from different panes (#580).
pub(crate) fn leaf_title_and_home(
&self,
window: Option<&Window>,
cx: &App,
) -> (String, Option<std::path::PathBuf>) {
let Some(leaf) = self.title_leaf(window, cx) else {
return (String::new(), None);
};
let leaf = leaf.read(cx);
(leaf.title.clone(), leaf.display_home(cx))
}
/// This tab as the shared label ladder reads it, together with what a `~`
/// in whatever it ends up named would mean.
///
/// [`TabView`](tty7_core::core::tab_view::TabView) is how a tab looks to
/// someone who is *not* the window showing it — the switcher listing
/// another window's workspace, `tty7 tab ls` on the far side of a socket.
/// Building one here from the live pane is what stops this window having a
/// second opinion: both sides then rank a given name, a title, an agent and
/// a directory through
/// [`TabView::label`](tty7_core::core::tab_view::TabView::label), so the
/// strip's answer to "which repo is this?" is the switcher's answer too.
///
/// Everything comes off the one leaf the tab names itself after, so the
/// title and the directory standing in for it can never describe different
/// panes (#580).
pub(crate) fn label_view(
&self,
window: Option<&Window>,
cx: &App,
) -> (
tty7_core::core::tab_view::TabView,
Option<std::path::PathBuf>,
) {
let name = self.name.clone();
let Some(leaf) = self.title_leaf(window, cx) else {
return (
tty7_core::core::tab_view::TabView {
id: self.tree_id.get(),
name,
title: String::new(),
osc_title: None,
cwd: None,
agent: None,
status: None,
live: false,
panes: 0,
},
None,
);
};
let leaf = leaf.read(cx);
let view = tty7_core::core::tab_view::TabView {
id: self.tree_id.get(),
name,
// The tree's `title` is the foreground process name — what it falls
// back on once a pane has said nothing about itself. A live pane's
// equivalent is the placeholder it answers to unprompted: any
// *other* default it was given (an SSH host, a workspace name) is a
// name tty7 chose for it deliberately, and `stated_title` hands
// those up as the title the pane is showing.
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
osc_title: leaf.stated_title().map(str::to_string),
cwd: leaf.cwd().map(|p| p.display().to_string()),
agent: leaf.agent(),
status: leaf.agent_session().map(|s| s.status),
live: !leaf.terminal.exited,
panes: self.pane.terminals().len(),
};
(view, leaf.display_home(cx))
}
pub(crate) fn git_status(
&self,
window: Option<&Window>,
cx: &App,
) -> Option<crate::terminal::git_status::GitStatus> {
let leaf = match window {
Some(window) => self.pane.focused_or_first(window, cx),
None => self.pane.first_leaf().and_then(|s| s.terminal().cloned()),
}?;
leaf.read(cx).git_status(cx)
}
pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> {
self.pane
.terminals()
.into_iter()
.find_map(|l| l.read(cx).agent())
}
/// The tab's most urgent agent leaf, named and reported by that one leaf.
///
/// `agent` and `agent_status` answer independently — the *first* leaf
/// carrying an agent, and the highest urgency found *anywhere* in the tab
/// — so reading them as a pair can put one pane's name beside another
/// pane's state, a row no leaf ever had. Anywhere both halves are shown
/// at once reads them from here instead (#543).
pub(crate) fn agent_row(
&self,
cx: &App,
) -> Option<(
crate::core::cli_agent::CLIAgent,
crate::core::cli_agent::AgentStatus,
)> {
use crate::core::cli_agent::AgentStatus;
let urgency = |s: AgentStatus| match s {
AgentStatus::Waiting => 3,
AgentStatus::Working => 2,
AgentStatus::Done => 1,
AgentStatus::Idle => 0,
};
self.pane
.terminals()
.into_iter()
.filter_map(|l| {
let view = l.read(cx);
let agent = view.agent()?;
// A pane whose agent is running but has never reported a
// session reads as idle, the same reading the badge has always
// given it.
let status = view
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle);
Some((agent, status))
})
.max_by_key(|(_, status)| urgency(*status))
}
pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> {
self.agent_row(cx).map(|(_, status)| status)
}
pub(crate) fn agent_unread_count(&self, cx: &App) -> usize {
use crate::core::cli_agent::AgentStatus;
if self.agent_status(cx) != Some(AgentStatus::Done) {
return 0;
}
self.pane
.terminals()
.into_iter()
.filter(|l| {
let v = l.read(cx);
v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
&& v.agent_result_unread()
})
.count()
}
}
/// What a rename box's contents mean for the tab's name.
#[derive(Debug, PartialEq, Eq)]
enum Rename {
/// The box still holds what it was seeded with, so nothing was asked for.
Unchanged,
/// Emptied on purpose: the tab goes back to following its pane.
Cleared,
/// A name the user typed.
Named(String),
}
/// Read a rename box against the label it was seeded with.
///
/// The box is prefilled with the label as rendered, so it is never empty when
/// it opens and a commit cannot tell "left alone" from "typed the same thing"
/// by looking at the value alone. `Blur` commits as readily as Enter does, so
/// without the comparison, opening the box and clicking away stored the label
/// as a name — and a name is a different thing from the title it was copied
/// from: it stops following the pane, freezing the tab on whatever it happened
/// to say at that moment, with no way to undo it.
fn rename_outcome(value: &str, prefill: &str) -> Rename {
let value = value.trim();
match value {
v if v == prefill.trim() => Rename::Unchanged,
"" => Rename::Cleared,
v => Rename::Named(v.to_string()),
}
}
pub(crate) struct Renaming {
/// The tab being renamed, by tree id rather than index: an index drifts
/// the moment any other tab closes or the strip reorders, which used to
/// force every unrelated tab event to throw the half-typed name away —
/// and left a window where the commit landed on the wrong tab (#598).
pub(crate) tab: tty7_core::core::machine::TabId,
pub(crate) input: Entity<InputState>,
/// What the box was prefilled with, so a commit can tell an untouched box
/// from a typed one. The box is seeded with the label already on screen,
/// which means it is never empty and every commit would otherwise store a
/// name — including the ones the user never typed.
prefill: String,
_subs: Vec<Subscription>,
}
pub(crate) struct WorkspaceRename {
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
pub(crate) struct GroupRename {
/// The group being renamed, by the key it had when the box opened.
///
/// A custom group *is* its name — there is no group record anywhere for
/// an id to point at, only the tabs that claim it. So renaming one means
/// rewriting every tab that says the old name, and this is what says
/// which those are.
pub(crate) key: crate::core::group_key::GroupKey,
pub(crate) input: Entity<InputState>,
pub(crate) _subs: Vec<Subscription>,
}
pub(crate) struct LoopbackForwardPanelState {
pub(crate) form_pane_id: Option<u64>,
pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>,
pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind,
pub(crate) mf_bind_host: Entity<InputState>,
pub(crate) mf_bind_port: Entity<InputState>,
pub(crate) mf_target_host: Entity<InputState>,
pub(crate) mf_target_port: Entity<InputState>,
pub(crate) mf_description: Entity<InputState>,
/// The rule the form is editing, whole rather than by id: an edit that
/// fails has to be able to put back what it took out, and the id alone
/// cannot describe the rule it named.
pub(crate) mf_editing: Option<crate::daemon::protocol::ManagedForward>,
/// Why the last Add or Save did not take, in the far side's own words.
/// Cleared the moment the form is closed or the edit is abandoned.
pub(crate) mf_error: Option<String>,
/// Return, on each of the boxes. Held here for the same reason the sftp
/// form holds its own: a live subscription on a box nothing is showing
/// would answer Return for a form that is gone.
pub(crate) mf_subs: Vec<Subscription>,
/// Whether the form is showing all five fields rather than the one.
///
/// Almost every forward anyone builds by hand is "bring the remote's :3000
/// over here", which is one number — and asking for five fields to collect
/// one number is what made the panel feel like paperwork. The rest of the
/// `ssh -L` grammar is still here, one disclosure away, for the forwards
/// that really do need it.
pub(crate) mf_advanced: bool,
}
pub struct Tty7App {
pub(crate) tabs: Vec<Tab>,
pub(crate) active: usize,
/// Hands out `Tab::last_used` stamps. A counter rather than a clock so two
/// activations in the same second still order.
tab_use_seq: std::cell::Cell<u64>,
/// A tab asked for before its workspace finished hydrating.
pending_tab: Option<tty7_core::core::machine::TabId>,
pub(crate) font_size: f32,
pub(crate) line_height: f32,
pub(crate) font_family: String,
pub(crate) font_family_bold: Option<String>,
pub(crate) font_family_italic: Option<String>,
pub(crate) font_fallbacks: Vec<String>,
pub(crate) font_features: Option<gpui::FontFeatures>,
terminal_cursor_style: ConfigCursorStyle,
terminal_scrollback_limit: usize,
_config_watch: Subscription,
_keystroke_watch: Subscription,
_activation_watch: Subscription,
_git_status_watch: Subscription,
_pane_liveness_watch: Subscription,
_appearance_watch: Subscription,
palette: Option<Entity<PaletteView>>,
palette_sub: Option<Subscription>,
/// Preset that was live when the palette's theme picker started previewing.
/// `Some` means the theme on screen is a preview that was never written to
/// disk, and closing the palette without confirming puts this one back.
theme_preview_restore: Option<String>,
pub(crate) closed: Vec<SessionTab>,
pub(crate) renaming: Option<Renaming>,
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
pub(crate) maximized: Option<Entity<TerminalView>>,
pub(crate) mod_hint_badges: bool,
pub(crate) mod_hint_gen: u64,
pub(crate) record_gen: u64,
pub(crate) home_focus: gpui::FocusHandle,
/// Whether the home page's cursor block is on this half-second.
///
/// A `bool` a timer flips, not a per-frame animation — the same shape the
/// terminal's own cursor uses. `with_animation(...).repeat()` asks for a
/// frame sixty times a second to change one glyph's opacity twice, and the
/// home page is otherwise perfectly still: it cost more than a live
/// terminal did, on a window with nothing open in it.
pub(crate) home_cursor_on: bool,
pub(crate) shells: ShellInventory,
pub(crate) shells_host: HostId,
pub(crate) loopback_panel: LoopbackForwardPanelState,
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
pub(crate) scm: crate::ui::scm::ScmPanelState,
pub(crate) diff_probes_inflight:
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
pub(crate) diff_probes_restale:
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
pub(crate) editor: crate::ui::code_editor::EditorPanelState,
pub(crate) sidebar_width: Rc<Cell<f32>>,
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
/// Whether the pointer is over the sidebar and over the tab strip. The
/// chrome tiles in each — new tab, the panel toggles, the app menu — are
/// drawn only while its own flag is set, so a window nobody is pointing at
/// carries no buttons at all. The right panel's own title bar is the
/// exception: its tiles are always painted while the panel is open.
/// How much width a settings row will actually get, measured once per
/// render. `settings_row` is called from page builders that never see the
/// window, and the answer differs per page — the SSH page spends a host
/// list on top of the nav before the row gets anything.
pub(crate) settings_row_width: Cell<f32>,
/// The window width the settings chrome sized itself against, measured in
/// the same pass. The pages that render their own chrome — the SSH host
/// list, the theme panel — are as blind to the window as `settings_row` is.
pub(crate) settings_viewport_w: Cell<f32>,
/// Cleared at the top of every settings render, then set by the first row
/// the live search matched, so exactly one row per page carries the anchor
/// the page scrolls to.
pub(crate) settings_hit_anchored: Cell<bool>,
last_settings_location: (SettingsSection, gpui::Point<gpui::Pixels>),
pub(crate) right_panel_width: Rc<Cell<f32>>,
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
/// The docked document column's share of the terminal column, live. Held
/// beside the config value rather than in it for the same reason the two
/// panel widths are: a drag writes this cell on every mouse move and the
/// config once, on mouse up.
pub(crate) document_ratio: Rc<Cell<f32>>,
pub(crate) document_dragging: Rc<Cell<bool>>,
pub(crate) right_panel_visible: bool,
pub(crate) right_panel_tab: RightPanelTab,
pub(crate) sidebar_collapsed: bool,
pub(crate) sidebar_scroll: gpui::ScrollHandle,
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// The pane the pointer is over, so only that one offers its drag handle.
pub(crate) pane_hover: Rc<Cell<Option<gpui::EntityId>>>,
pub(crate) pane_drag: crate::ui::pane_drag::PaneDragState,
/// Where a tab held over the layout would be grafted in, as the last
/// painted frame read it. The same bargain the pane drag's landing keeps:
/// offered only once the tree agrees it changes something, so releasing
/// over a highlight always does what the highlight showed.
pub(crate) tab_merge: Cell<
Option<(
tty7_core::core::machine::TabId,
crate::ui::pane_drag::DropZone<gpui::EntityId>,
)>,
>,
/// The pane a drag would put down as a tab of its own, and where among the
/// tabs it would go. Read back a frame later by the drop, like the two
/// landings above.
pub(crate) pane_detach: Cell<Option<(gpui::EntityId, usize)>>,
/// Where the strip drew each tab's chip and where the sidebar drew each
/// tab's row, by tab — the geometry a pane dropped on either of them reads
/// its new place out of.
///
/// Written from paint, so what a frame reads is where things were on the
/// frame before. That is exactly as good as it needs to be: neither the
/// strip nor the sidebar moves while a drag is in flight. A tab with no
/// rectangle was not drawn — scrolled out of a full strip, filtered out of
/// the sidebar — and takes no part in the reading.
pub(crate) strip_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
pub(crate) sidebar_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
/// Where each custom group's block was drawn last frame, so a tab held
/// over one can be told which group it is over. Only custom groups are
/// here: a repo group's membership is decided by cwd, so dropping a tab
/// into one has no meaning to record.
pub(crate) sidebar_group_slots:
Rc<RefCell<Vec<(crate::core::group_key::GroupKey, Bounds<Pixels>)>>>,
/// Where the active tab's panes were last drawn, which is the frame of
/// reference a drag's landing is worked out in.
pub(crate) pane_area: Rc<Cell<Option<Bounds<Pixels>>>>,
pub(crate) sidebar_search: Entity<InputState>,
pub(crate) file_search: Entity<InputState>,
_sidebar_search_sub: Subscription,
_file_search_sub: Subscription,
settings: Option<SettingsState>,
pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState,
/// A close question is on screen. It carries no target: the answer acts on
/// the tab or pane captured when the question was raised, not on whatever
/// the app happens to be pointing at by the time it is answered.
close_prompt_open: bool,
window_bounds: Bounds<Pixels>,
pub(crate) workspace: WorkspaceId,
pub(crate) workspace_rename: Option<WorkspaceRename>,
pub(crate) group_rename: Option<GroupRename>,
window_title: std::cell::RefCell<String>,
pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>,
pub(crate) switcher: Option<crate::ui::switcher::Switcher>,
pub(crate) host_snapshots: std::collections::HashMap<
crate::ui::host_registry::HostId,
crate::ui::switcher::HostSnapshot,
>,
/// Errors reported for a remote host that should be shown inside that host's
/// switcher group instead of as a global modal or toast.
pub(crate) remote_host_errors: std::collections::HashMap<String, String>,
/// Parked switcher groups (#485) whose notice the user dismissed by key —
/// the entries stay, only the "will not reconnect" block is hidden.
pub(crate) parked_dismissed: std::collections::HashSet<String>,
/// A create asked of a machine that was not connected yet; the connect
/// finishing is what completes it (see `Tty7App::finish_connect`).
pub(crate) pending_create: Option<crate::ui::switcher::PendingCreate>,
/// A create whose connect was refused for the control dialect — the state
/// the "update server" button answers. Set aside rather than dropped, so
/// the update the user runs next still ends in the workspace they asked
/// for. Held apart from `pending_create` on purpose: only a connect to the
/// same machine may spend it, and giving the machine up (dismissing the
/// refusal, disconnecting) discards it.
pub(crate) parked_create: Option<crate::ui::switcher::PendingCreate>,
/// Why the window opened with no terminal in it. Shown on the home screen,
/// which is otherwise indistinguishable from having closed everything.
pub(crate) startup_error: Option<gpui::SharedString>,
}
/// What a raised close question is about. Tabs are named by their id, not their
/// index: a tab that exits on its own while the question is on screen shifts
/// every index after it, and answering "Close" must not then end a bystander.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum CloseTarget {
Tab(tty7_core::core::machine::TabId),
Pane,
}
/// Why closing needs a question first. Closing a tab is the highest-frequency
/// destructive key in any terminal, and the product's headline claim is that
/// shells outlive the app — so the one action that permanently ends one has to
/// name what it is about to end.
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum CloseReason {
LiveSsh,
Busy(crate::terminal::view::PaneBusy),
}
/// The question to put to the user before ending work that is still going on.
fn close_prompt(ends_the_tab: bool, reason: &CloseReason) -> (String, String) {
use crate::terminal::view::PaneBusy;
use crate::ui::i18n::L10nKey;
match reason {
CloseReason::LiveSsh => (
t(L10nKey::CloseSshConnectionTitle).to_string(),
t(L10nKey::CloseSshConnectionBody).to_string(),
),
CloseReason::Busy(busy) => {
let title = match ends_the_tab {
true => t(L10nKey::CloseTabBusyTitle),
false => t(L10nKey::ClosePaneBusyTitle),
};
let body = match busy {
PaneBusy::Command(what) => t_fmt(L10nKey::CloseBusyCommandBody, &[("what", what)]),
PaneBusy::Agent(name) => t_fmt(L10nKey::CloseBusyAgentBody, &[("agent", name)]),
};
(title.to_string(), body)
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ForkPlacement {
NewTab,
Split { axis: Axis, before: bool },
}
pub(crate) struct TabAgentSession {
pub(crate) fork_label: Option<&'static str>,
pub(crate) session_id: Option<String>,
pub(crate) remote: bool,
}
impl TabAgentSession {
pub(crate) fn forkable(&self) -> bool {
self.fork_label.is_some() && self.session_id.is_some() && !self.remote
}
}
/// Maps a backdrop onto the settings dropdown. The dropdown lists the
/// presets the current Windows build supports, plus the stored value even
/// when unsupported here (see `theme::backdrop_options`), so the label
/// always matches what the window actually resolves to.
#[cfg(target_os = "windows")]
fn window_backdrop_index(backdrop: WindowBackdrop) -> usize {
crate::ui::theme::backdrop_options(backdrop)
.iter()
.position(|candidate| *candidate == backdrop)
.unwrap_or(0)
}
#[cfg(target_os = "windows")]
fn window_backdrop_from_index(idx: usize, current: WindowBackdrop) -> WindowBackdrop {
crate::ui::theme::backdrop_options(current)
.get(idx)
.copied()
.unwrap_or(WindowBackdrop::Auto)
}
#[cfg(target_os = "windows")]
fn window_backdrop_label_key(backdrop: WindowBackdrop) -> L10nKey {
match backdrop {
WindowBackdrop::Auto => L10nKey::SettingsBackdropAuto,
WindowBackdrop::Blur => L10nKey::SettingsBackdropBlur,
WindowBackdrop::Mica => L10nKey::SettingsBackdropMica,
WindowBackdrop::MicaAlt => L10nKey::SettingsBackdropMicaAlt,
WindowBackdrop::Acrylic => L10nKey::SettingsBackdropAcrylic,
WindowBackdrop::Off => L10nKey::SettingsBackdropOff,
}
}
#[cfg(target_os = "windows")]
fn window_backdrop_labels(backdrop: WindowBackdrop) -> Vec<String> {
crate::ui::theme::backdrop_options(backdrop)
.iter()
.map(|backdrop| t(window_backdrop_label_key(*backdrop)).to_string())
.collect()
}
/// What a full-window overlay (settings, the opened file, the diff view)
/// paints between its own fill and its content.
///
/// Those overlays fill opaquely on purpose, so the OS backdrop cannot show
/// through their text — but that fill also sits on top of the background
/// image the workspace root paints, and would erase it for as long as an
/// overlay is open. So each one repaints the image, then the workspace's own
/// translucent fill over it. That second layer is what keeps the overlay
/// readable: it dims the image to exactly the strength it had when these
/// overlays were themselves translucent, before they were made opaque.
///
/// Empty when the theme has no image — then the opaque fill alone is already
/// what the overlay wants, and a second pass of the same paint buys nothing.
pub(crate) fn overlay_surface_layers(cx: &App) -> Vec<gpui::Div> {
match window_background_image_layer(cx) {
Some(image) => vec![
image,
div()
.absolute()
.inset_0()
.bg(crate::ui::theme::workspace_background(cx)),
],
None => Vec::new(),
}
}
/// The theme's background image as a full-bleed layer.
pub(crate) fn window_background_image_layer(cx: &App) -> Option<gpui::Div> {
let image = cx
.try_global::<crate::ui::presets::ActiveBackground>()?
.image
.clone()?;
Some(
div()
.absolute()
.inset_0()
.overflow_hidden()
.opacity(image.opacity)
.child(
img(image.path)
.size_full()
.object_fit(gpui::ObjectFit::Cover),
),
)
}
/// Clears the window overrides that are effective on the current platform.
/// `backdrop_is_local` models whether the Windows-only backdrop participates
/// in this platform's rendering and therefore belongs to its reset operation.
fn clear_window_override_values(config: &mut Config, backdrop_is_local: bool) {
config.window_opacity = None;
config.window_blur = None;
if backdrop_is_local {
config.window_backdrop = WindowBackdrop::Auto;
}
}
/// The id the fullscreen hint is pushed under, so that entering again replaces
/// it and leaving takes it away.
struct FullscreenHint;
/// Whether the title bar carries minimize, maximize and close right now.
///
/// Not in fullscreen. A fullscreen window has no caption: Windows clears
/// `WS_CAPTION` and answers `HTCLIENT` along the whole top edge, so the three
/// buttons would draw, light up under the pointer and do nothing when clicked.
/// The row they sit at the end of stays, because it is also the tab strip.
///
/// Never on macOS, which draws no buttons of its own: those are the system's
/// traffic lights, and the system hides them itself.
pub(crate) fn window_controls_drawn(fullscreen: bool) -> bool {
!cfg!(target_os = "macos") && !fullscreen
}
/// How much of the title bar's trailing end the window buttons take.
pub(crate) fn window_controls_w(fullscreen: bool) -> f32 {
match window_controls_drawn(fullscreen) {
true => WINDOW_CONTROLS_W,
false => 0.,
}
}
impl Tty7App {
pub fn for_workspace(
id: Option<WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::for_workspace_at(id, None, window, cx)
}
pub fn for_workspace_at(
id: Option<WorkspaceId>,
mut initial_cwd: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let restore = cx.global::<Config>().restore_session;
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
let workspace = WorkspaceStore::claim(cx, id);
let is_remote = WorkspaceStore::all(cx)
.get(workspace)
.is_some_and(|w| w.is_remote());
// Tabs that exist on the machine are shown whatever the restore
// setting says: that setting decides whether a window comes back at
// launch, not whether an open one shows what is really in it. The
// `else` arm below saves this window's session, and saving an empty
// one over a live tree would erase it.
let on_machine = id.is_some_and(|id| crate::ui::machine_mirror::machine_holds_tabs(cx, id));
let hydrate = on_machine || (known && (restore || is_remote));
let session = hydrate.then(Session::default);
// A window that is about to pull its layout cannot also open the folder
// the launch asked for as its first terminal — the pull would find a
// window that already has a tab, decline to adopt into it, and push
// that one tab back as the whole workspace. So the folder travels with
// the hydration and becomes a tab once the layout is up.
let open_after_hydrate = hydrate.then(|| initial_cwd.take()).flatten();
let app = Self::with_session_at(Some(workspace), session, initial_cwd, window, cx);
if hydrate {
match open_after_hydrate {
Some(cwd) => {
crate::ui::tree_sync::hydrate_window_then_open(cx, workspace, cwd);
}
None => crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace),
}
} else {
if !is_remote {
crate::ui::tree_sync::mark_window_informed(cx, workspace);
}
app.save_session(cx);
}
Self::prompt_daemon_version_mismatch(window, cx);
crate::ui::remote_connect::register(cx);
crate::ui::remote_connect::sweep_wsl(cx);
Self::prompt_remote_daemon_mismatch(window, cx);
app.reopen_remote_at_startup(cx);
app
}
/// Put a version-mismatched local server to the user, with no third way out.
///
/// Both handshakes compare their version for equality and hang up on
/// anything else — the pane protocol in `daemon::spawn::ensure_running`, the
/// control dialect in `host::server`'s hello. So a server whose number
/// disagrees cannot be talked round, and carrying on beside it is not a
/// degraded mode but a broken one: panes still spawn while every
/// machine-tree call is refused, which is how a window comes to open with no
/// tabs and save none of the ones you make. This used to be offered as "Keep
/// Shells", and taking it was indistinguishable from the bug.
///
/// Restart or quit, then. Quitting is the half that destroys nothing: the
/// server and every shell under it keep running, which is what makes it a
/// real answer for someone who would rather go install the matching build
/// than lose a session mid-flight.
fn prompt_daemon_version_mismatch(window: &mut Window, cx: &mut Context<Self>) {
let Some(mismatch) = crate::daemon::spawn::take_mismatched_daemon() else {
return;
};
let ours = crate::daemon::protocol::PROTOCOL_VERSION;
let detail = match &mismatch {
DaemonMismatch::Protocol(Some(v)) => t_fmt(
L10nKey::AppRestartServerMismatchDetail,
&[
("build", &v.build.to_string()),
("protocol", &v.protocol.to_string()),
("ours", &ours.to_string()),
],
),
DaemonMismatch::Protocol(None) => t(L10nKey::AppRestartServerOldDetail).to_string(),
// The handshake only reports disagreement, not direction, and a
// daemon left behind by a newer build is as much a mismatch as one
// left behind by an older. Calling that one "older" would be the
// same wrong guess the remote path stopped making.
DaemonMismatch::Dialect(refusal) => t_fmt(
if refusal.peer < refusal.ours {
L10nKey::AppRestartServerDialectDetail
} else {
L10nKey::AppRestartServerDialectNewerDetail
},
&[
("build", &refusal.peer_build),
("dialect", &refusal.peer.to_string()),
("ours", &refusal.ours.to_string()),
],
),
};
// The one prompt here that does not use `confirm_answers`, because
// neither answer is "leave it alone" — the app cannot carry on beside a
// server it cannot speak to. With nothing safe to give Escape, this
// keeps Quit at index 0 where Return lands: it arrives unasked at
// launch, the moment a stray Return is most likely, and quitting loses
// no sessions while restarting the server ends every one of them.
let answer = window.prompt(
PromptLevel::Warning,
t(L10nKey::AppRestartServerTitle),
Some(&detail),
&[t(L10nKey::CmdQuitTty7), t(L10nKey::RestartServer)],
cx,
);
cx.spawn(async move |this, cx| match answer.await {
Ok(1) => {
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
}
Ok(_) => {
let _ = cx.update(|cx| cx.quit());
}
// Dismissed without an answer: the window went away before the
// question was settled. Arm it again so the next window asks, rather
// than letting the state this prompt exists to prevent slip through
// the gap.
Err(_) => crate::daemon::spawn::note_daemon_mismatch(mismatch),
})
.detach();
}
pub(crate) fn with_session(
workspace: Option<WorkspaceId>,
session: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::with_session_at(workspace, session, None, window, cx)
}
fn with_session_at(
workspace: Option<WorkspaceId>,
session: Option<Session>,
initial_cwd: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let workspace = workspace.unwrap_or_default();
let pane_ws = crate::ui::remote_workspace::pane_workspace_for(cx, workspace);
let (
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
font_fallbacks,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_family_bold.clone(),
cfg.font_family_italic.clone(),
cfg.font_fallbacks.clone(),
cfg.font_features
.as_ref()
.map(crate::core::config::gpui_font_features),
cfg.cursor_style,
cfg.scrollback_limit,
)
};
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx);
let editor = crate::ui::code_editor::EditorPanelState::new(window, cx);
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1"));
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
let mf_description = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderDescription))
});
let sidebar_width = cx.global::<Config>().sidebar_width;
let right_panel_width = cx.global::<Config>().right_panel_width;
let document_ratio = cx.global::<Config>().document_ratio;
let right_panel_visible = cx.global::<Config>().right_panel_visible;
let right_panel_tab = cx.global::<Config>().right_panel_tab;
let scm_graph_expanded = cx.global::<Config>().scm_graph_expanded;
let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed;
let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| {
this.reload_from_config(window, cx)
});
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
let git_status_watch =
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| {
this.maybe_refresh_diff_overlay(cx);
this.right_panel_refresh_changes(cx);
cx.notify();
});
cx.default_global::<crate::terminal::pane_liveness::PaneLivenessCache>();
let pane_liveness_watch = cx
.observe_global::<crate::terminal::pane_liveness::PaneLivenessCache>(|_this, cx| {
cx.notify();
});
let this = cx.weak_entity();
let keystroke_watch = cx.intercept_keystrokes(move |_ev, _window, cx| {
let _ = this.update(cx, |this, cx| this.dismiss_mod_hint(cx));
});
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
this.dismiss_mod_hint(cx);
this.set_link_modifier(false, cx);
// A modifier released over another window never reports here, so a
// Ctrl+Tab panel would hang waiting for a commit that cannot come.
this.switcher_release_hold(cx);
if window.is_window_active() {
WorkspaceStore::focus(cx, this.workspace);
this.refresh_git_status_all(cx);
}
});
let this = cx.weak_entity();
let appearance_watch = window.observe_window_appearance(move |window, cx| {
crate::ui::theme::note_system_appearance(window, cx);
if !cx.global::<Config>().theme_follow_system {
return;
}
apply_theme(Some(window), cx);
let _ = this.update(cx, |this, cx| {
this.rebuild_theme_editor(window, cx);
this.sync_window_opacity_slider(window, cx);
cx.notify();
});
});
apply_theme(Some(window), cx);
set_menus(cx);
let mut startup_error: Option<gpui::SharedString> = None;
let (tabs, active) = match session {
None => match new_terminal(
pane_ws.clone(),
Some(workspace),
font_size,
initial_cwd,
None,
None,
window,
cx,
) {
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
Err(e) => {
log::error!("first terminal failed to start: {e}");
// The home screen is what a user sees when they have closed
// everything, and it used to be what they saw when tty7
// could not open anything — silently, on the very first
// launch, with the cause only in a log file.
startup_error = Some(gpui::SharedString::from(t_fmt(
L10nKey::AppOpenTerminalFailed,
&[("error", &e.to_string())],
)));
(Vec::new(), 0)
}
},
some => {
let (tabs, active, dropped) =
tabs_from_session(pane_ws.as_ref(), workspace, some, font_size, window, cx);
if dropped > 0 {
startup_error = Some(gpui::SharedString::from(t_plural(
L10nKey::AppTabsNotRestored,
dropped,
&[],
)));
}
(tabs, active)
}
};
let sidebar_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchTabs))
});
let sidebar_search_sub =
cx.subscribe_in(&sidebar_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let file_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchFiles))
});
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let mut app = Self {
tabs,
active,
tab_use_seq: std::cell::Cell::new(0),
pending_tab: None,
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
font_fallbacks,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
_config_watch: config_watch,
_keystroke_watch: keystroke_watch,
_activation_watch: activation_watch,
_git_status_watch: git_status_watch,
_pane_liveness_watch: pane_liveness_watch,
_appearance_watch: appearance_watch,
palette: None,
palette_sub: None,
theme_preview_restore: None,
closed: Vec::new(),
renaming: None,
worktree_prompt: None,
maximized: None,
mod_hint_badges: false,
mod_hint_gen: 0,
record_gen: 0,
home_focus: cx.focus_handle(),
home_cursor_on: true,
shells: ShellInventory::default(),
shells_host: HostId::LOCAL,
loopback_panel: LoopbackForwardPanelState {
form_pane_id: None,
managed: Vec::new(),
mf_kind: crate::daemon::protocol::SshForwardKind::Local,
mf_bind_host,
mf_bind_port,
mf_target_host,
mf_target_port,
mf_description,
mf_editing: None,
mf_error: None,
mf_subs: Vec::new(),
mf_advanced: false,
},
sftp_panel,
right_panel: Default::default(),
scm: crate::ui::scm::ScmPanelState {
graph: crate::ui::scm::GraphState {
expanded: scm_graph_expanded,
..Default::default()
},
..Default::default()
},
diff_probes_inflight: Default::default(),
diff_probes_restale: Default::default(),
file_tree,
editor,
sidebar_width: Rc::new(Cell::new(sidebar_width)),
sidebar_dragging: Rc::new(Cell::new(false)),
settings_row_width: Cell::new(f32::MAX),
settings_viewport_w: Cell::new(f32::MAX),
settings_hit_anchored: Cell::new(false),
last_settings_location: (SettingsSection::General, gpui::point(px(0.), px(0.))),
right_panel_width: Rc::new(Cell::new(right_panel_width)),
right_panel_dragging: Rc::new(Cell::new(false)),
document_ratio: Rc::new(Cell::new(document_ratio)),
document_dragging: Rc::new(Cell::new(false)),
right_panel_visible,
right_panel_tab,
sidebar_collapsed,
sidebar_scroll: gpui::ScrollHandle::new(),
reorder: Rc::new(RefCell::new(None)),
pane_hover: Rc::new(Cell::new(None)),
pane_drag: Rc::new(RefCell::new(None)),
tab_merge: Cell::new(None),
pane_detach: Cell::new(None),
strip_slots: Rc::new(RefCell::new(Vec::new())),
sidebar_slots: Rc::new(RefCell::new(Vec::new())),
sidebar_group_slots: Rc::new(RefCell::new(Vec::new())),
pane_area: Rc::new(Cell::new(None)),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
file_search,
_file_search_sub: file_search_sub,
settings: None,
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
close_prompt_open: false,
window_bounds: window_bounds_to_remember(window),
workspace,
workspace_rename: None,
group_rename: None,
window_title: std::cell::RefCell::new(String::new()),
connect: None,
switcher: None,
host_snapshots: std::collections::HashMap::new(),
remote_host_errors: std::collections::HashMap::new(),
parked_dismissed: std::collections::HashSet::new(),
pending_create: None,
parked_create: None,
startup_error,
};
if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 {
crate::ui::tray::init(cx);
}
app.refresh_shells(cx);
cx.on_app_quit(|app, cx| {
app.save_session(cx);
crate::core::window_state::WindowState::from_bounds(app.window_bounds).save();
async move {}
})
.detach();
cx.observe_window_bounds(window, |this, window, _cx| {
this.window_bounds = window_bounds_to_remember(window);
})
.detach();
// The home page's cursor, on the terminal's own schedule. It ticks
// whether or not the page is up — a timer that wakes twice a second to
// compare a `Vec`'s length against zero costs nothing — but only asks
// for a frame when the page is the thing on screen.
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(HOME_CURSOR_BLINK_MS))
.await;
if this
.update(cx, |this, cx| {
if this.tabs.is_empty() {
this.home_cursor_on = !this.home_cursor_on;
cx.notify();
}
})
.is_err()
{
break;
}
}
})
.detach();
let weak_app = cx.weak_entity();
window.on_window_should_close(cx, move |_window, cx| {
if let Some(app) = weak_app.upgrade() {
app.update(cx, |app, cx| app.prepare_window_close(cx));
}
true
});
app.focus_active(window, cx);
app
}
pub(crate) fn save_session(&self, cx: &mut App) {
for view in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) {
let Some(owner) = view.read(cx).owner_workspace() else {
continue;
};
if owner != self.workspace {
log::error!(
"save_session: window of workspace {} is recording pane {} \
that was created for workspace {owner} — cross-workspace \
write detected, please report this",
self.workspace,
view.read(cx).pane_id,
);
}
}
WorkspaceStore::record_geometry(
cx,
self.workspace,
WindowState::from_bounds(self.window_bounds),
);
crate::ui::tree_sync::sync_window(self, cx);
}
pub(crate) fn detach_workspace(&self, cx: &mut App) {
self.save_session(cx);
let answered = WorkspaceStore::machine_is_connected(cx, self.workspace);
if self.tabs.is_empty()
&& answered
&& crate::ui::tree_sync::workspace_is_disposable(cx, self.workspace)
{
crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| {
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
});
WorkspaceStore::remove(cx, self.workspace);
} else {
WorkspaceStore::close_window(cx, self.workspace);
}
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
crate::ui::tree_sync::forget(cx, self.workspace);
crate::ui::windows::refresh_menu(cx);
}
/// Opens a second window, on a workspace of its own.
///
/// A window on *this* workspace is not the other reading of "new window";
/// it is a thing the app cannot hold. `WindowRegistry` is keyed by
/// workspace — `window_for`, `app_for`, `unregister` and `rebind` all
/// address a window by the workspace it shows — and `windows::open`
/// answers a workspace that already has a window by activating it. Asking
/// for the current one here would raise the window you are already in.
///
/// So this is the same call the switcher makes for "Open in New Window",
/// with no workspace named: a fresh one, which is also what a new window
/// holds everywhere else it is offered.
pub(crate) fn new_window(&self, cx: &mut App) {
crate::ui::windows::open(cx, None);
}
fn prepare_window_close(&self, cx: &mut App) {
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
self.detach_workspace(cx);
if last_window {
// With a tray icon, closing the last window retires to the
// tray: the daemon stays reachable (show / quit-and-stop)
// instead of being orphaned behind a dead icon. Without one
// the app quits — the only way it stays visible at all.
//
// The icon has to actually be up, not merely asked for: the
// backend can fail for the whole run (a Linux session with no
// StatusNotifier host), and retiring into an icon that never
// appeared leaves a process with no window and no tray — no
// way back in, and the daemon still held.
let retire_to_tray =
cx.global::<Config>().show_tray_icon && crate::ui::tray::icon_is_up();
if !retire_to_tray {
cx.spawn(async move |cx| {
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
}
}
fn close_window(&self, window: &mut Window, cx: &mut App) {
self.prepare_window_close(cx);
window.remove_window();
}
pub(crate) fn teardown_workspace_forwards(&self, cx: &gpui::App) {
let Some(route) = self
.tabs
.iter()
.flat_map(|tab| tab.pane.terminals())
.find_map(|leaf| {
let view = leaf.read(cx);
let workspace = view.workspace().cloned()?;
Some(ForwardRoute {
pane_id: view.pane_id,
workspace: Some(workspace),
})
})
else {
return;
};
cx.background_executor()
.spawn(async move {
let left = route.teardown();
if !left.is_empty() {
log::warn!("{} forwards survived a workspace teardown", left.len());
}
})
.detach();
}
pub(crate) fn stop_workspace(
&mut self,
id: WorkspaceId,
window: &mut Window,
cx: &mut Context<Self>,
) {
crate::ui::windows::confirm_and_stop(cx, window, id);
cx.notify();
}
pub(crate) fn delete_workspace(
&mut self,
id: WorkspaceId,
window: &mut Window,
cx: &mut Context<Self>,
) {
crate::ui::windows::confirm_and_delete(cx, window, id);
cx.notify();
}
pub(crate) fn select_workspace_slot(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((id, _open)) = crate::ui::windows::menu_order(cx).get(index).copied() else {
return;
};
self.reveal_workspace(id, window, cx);
}
pub(crate) fn reveal_workspace(
&mut self,
id: WorkspaceId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, id) {
let _ = handle.update(cx, |_, other, _| other.activate_window());
return;
}
// Switching workspaces happens in place. A second window is something
// you ask for — with the platform modifier, or "Open in New Window".
self.switch_workspace(Some(id), window, cx);
}
/// Trades this window's workspace for another one. `None` starts a fresh
/// workspace here rather than opening one in a new window.
pub(crate) fn switch_workspace(
&mut self,
id: Option<WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous = self.workspace;
if id == Some(previous) {
return;
}
// Anything parked for the workspace we are leaving is now meaningless.
self.pending_tab = None;
if self.tabs.is_empty() && crate::ui::tree_sync::workspace_is_disposable(cx, previous) {
crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| {
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
});
WorkspaceStore::remove(cx, previous);
} else if self.tabs.is_empty() {
WorkspaceStore::close_window(cx, previous);
} else {
self.save_session(cx);
WorkspaceStore::close_window(cx, previous);
}
crate::ui::tree_sync::forget(cx, previous);
let claimed = WorkspaceStore::claim(cx, id);
crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed);
crate::ui::remote_workspace::RemoteLinks::supervise(cx, claimed);
// Forgotten on the way in as well as on the way out. `adopt_workspace`
// puts the empty session up before the pull below orders the real one,
// and it saves what it put up: a window showing nothing, syncing
// against whatever this workspace was left Primed and informed with
// the last time it was visited, which is a Full diff that closes every
// tab on the machine (#716). Arriving speaks for nothing until a pull
// says otherwise.
crate::ui::tree_sync::forget(cx, claimed);
self.adopt_workspace(claimed, Session::default(), window, cx);
// This method runs under the app's own update lease, so the tabs the
// pull must see are the ones just adopted here — reading the app back
// out of `cx` (as `tabs_on_screen` would) is an abort in gpui.
let showing = self.tabs.iter().map(|t| t.tree_id.get()).collect();
crate::ui::tree_sync::hydrate_window_with_tabs(cx, claimed, showing);
}
pub(crate) fn adopt_workspace(
&mut self,
id: WorkspaceId,
session: Session,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous_host = self.spawn_host(cx);
self.workspace = id;
self.rebind_host(previous_host, cx);
self.refresh_shells(cx);
let font_size = self.font_size;
let pane_ws = self.window_workspace(cx);
let (tabs, active, dropped) = tabs_from_session(
pane_ws.as_ref(),
self.workspace,
Some(session),
font_size,
window,
cx,
);
if dropped > 0 {
window.push_notification(t_plural(L10nKey::AppTabsNotRestored, dropped, &[]), cx);
}
self.tabs = tabs;
self.active = active;
self.maximized = None;
self.save_session(cx);
crate::ui::windows::refresh_menu(cx);
self.focus_active(window, cx);
cx.notify();
}
fn reopen_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(st) = self.closed.pop() else {
return;
};
let pane_ws = self.window_workspace(cx);
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(pane_ws.as_ref()));
let Some(pane) = session_to_pane(
pane_ws.as_ref(),
self.workspace,
&st.pane,
alive.as_ref(),
self.font_size,
window,
cx,
) else {
window.push_notification(t(L10nKey::AppReopenTabFailed), cx);
self.closed.push(st);
return;
};
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(
insert_at,
Tab {
pane,
name: st.name,
last_focused: None,
zoomed: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
},
);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
pub(crate) fn owns_leaf(&self, leaf_id: u64) -> bool {
self.tabs.iter().any(|t| {
t.pane
.leaves()
.iter()
.any(|l| l.entity_id().as_u64() == leaf_id)
})
}
pub(crate) fn agent_rows(&self, cx: &App) -> Vec<crate::ui::tray::AgentRow> {
use crate::core::cli_agent::AgentStatus;
let mut agents = Vec::new();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let view = leaf.read(cx);
let Some(agent) = view.agent() else { continue };
let status = view
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle);
let dir = view
.cwd()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));
let branch = view.git_status(cx).map(|g| g.branch);
let detail = match (dir, branch) {
(Some(dir), Some(branch)) => format!("{dir} @ {branch}"),
(Some(dir), None) => dir,
(None, _) => String::new(),
};
agents.push(crate::ui::tray::AgentRow {
leaf_id: leaf.entity_id().as_u64(),
agent,
status,
detail,
});
}
}
agents
}
pub(crate) fn handle_tray_action(
&mut self,
action: crate::ui::tray::TrayAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
use crate::ui::tray::TrayAction;
fn surface_window(window: &mut Window, cx: &mut App) {
cx.activate(true);
window.activate_window();
}
match action {
TrayAction::ShowWindow => surface_window(window, cx),
TrayAction::RevealPane { leaf_id } => {
let tab_ix = self.tabs.iter().position(|t| {
t.pane
.leaves()
.iter()
.any(|l| l.entity_id().as_u64() == leaf_id)
});
if let Some(ix) = tab_ix {
self.activate(ix, window, cx);
if self
.maximized
.as_ref()
.is_some_and(|m| m.entity_id().as_u64() != leaf_id)
{
self.maximized = None;
}
if let Some(leaf) = self.tabs[ix]
.pane
.leaves()
.into_iter()
.find(|l| l.entity_id().as_u64() == leaf_id)
{
self.tabs[ix].last_focused = Some(leaf.entity_id());
self.focus_leaf(&leaf, window, cx);
}
cx.notify();
}
surface_window(window, cx);
}
TrayAction::SetNotifyMode(mode) => self.set_notify_mode(mode, cx),
TrayAction::OpenSettings => {
surface_window(window, cx);
if self.settings.is_none() {
self.toggle_settings(window, cx);
}
}
TrayAction::CheckForUpdates => {
surface_window(window, cx);
self.check_for_updates_now(window, cx);
}
// One exit path with one meaning: quit the app and stop the server
// (after the confirmation that protects running shells). Closing
// the window is the keep-everything exit — it retires to the tray.
TrayAction::Quit => self.quit_stop_sessions(window, cx),
}
}
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.activate(true);
window.activate_window();
let answer = window.prompt(
PromptLevel::Warning,
t(crate::ui::i18n::L10nKey::QuitStopServerTitle),
Some(t(crate::ui::i18n::L10nKey::QuitStopServerBody)),
&crate::ui::confirm_answers(
t(crate::ui::i18n::L10nKey::QuitAndStop),
t(crate::ui::i18n::L10nKey::Cancel),
),
cx,
);
cx.spawn(async move |_this, cx| {
if !matches!(answer.await, Ok(0)) {
return;
}
cx.background_spawn(async { crate::daemon::spawn::stop() })
.await;
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
pub(crate) fn restart_window_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else {
self.restart_daemon(window, cx);
return;
};
let target = remote.target.clone();
let label = crate::ui::remote_connect::route_label(cx, &remote);
if !target.hosts_our_server() {
window.push_notification(
t_fmt(L10nKey::AppRestartServerNoServer, &[("label", &label)]),
cx,
);
return;
}
// A server the other side of a dialect bump has nothing to restart
// *into*: the binary this build launches was never installed over
// there, and the installer now refuses rather than killing the one
// that is running. Updating is the move that works, so offer that,
// under its own name and with its own warning about ending sessions.
if let Some(crate::ui::remote_workspace::RemoteStatus::ServerMismatch(refusal)) =
self.remote_status(cx)
{
let action = crate::ui::remote_workspace::mismatch_action_key(&refusal);
self.confirm_replace_remote_server(target, label, action, window, cx);
return;
}
self.confirm_restart_remote_server(target, label, window, cx);
}
/// The palette's "Update tty7 server" for this computer. The app bundle
/// already carries the new server, so updating it is restarting onto this
/// build — the same restart, with the same confirmation, as everywhere
/// else. Only when the running server is already this build is there
/// nothing to do, and then the user is told so rather than asked to end
/// their shells for no change.
fn update_local_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if crate::daemon::spawn::local_daemon_is_this_build() {
window.push_notification(
t_fmt(
L10nKey::AppLocalServerAlreadyCurrent,
&[("build", env!("CARGO_PKG_VERSION"))],
),
cx,
);
return;
}
self.restart_daemon(window, cx);
}
/// The same for the machine this window's workspace lives on. The palette
/// only offers it there, but the row can outlive a workspace switch, so the
/// checks are made again rather than trusted.
fn update_window_remote_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else {
self.update_local_server(window, cx);
return;
};
let target = remote.target.clone();
let label = crate::ui::remote_connect::route_label(cx, &remote);
if !target.hosts_our_server() {
window.push_notification(
t_fmt(L10nKey::AppRestartServerNoServer, &[("label", &label)]),
cx,
);
return;
}
self.confirm_update_remote_server(target, label, window, cx);
}
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Two different actions wearing one name. Where the service can rewrite
// itself in place, nothing in a pane is interrupted and promising the
// user a bloodbath would be a lie that costs them the feature; where it
// cannot, every running command really does end, and that is the one
// thing they need to be told before they agree.
let in_place =
crate::daemon::spawn::local_daemon_supports(crate::daemon::protocol::FEATURE_HANDOFF);
let answer = window.prompt(
PromptLevel::Warning,
t(L10nKey::AppRestartServerTitle),
Some(t(if in_place {
L10nKey::AppRestartServerBodyInPlace
} else {
L10nKey::AppRestartServerBody
})),
&crate::ui::confirm_answers(
t(L10nKey::AppRestart),
t(crate::ui::i18n::L10nKey::Cancel),
),
cx,
);
cx.spawn(async move |this, cx| {
if !matches!(answer.await, Ok(0)) {
return;
}
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
})
.detach();
}
fn restart_daemon_confirmed(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
if this
.update_in(cx, |this, _window, cx| {
this.save_session(cx);
this.maximized = None;
this.tabs.clear();
this.active = 0;
cx.notify();
})
.is_err()
{
return;
}
let restarted = cx
.background_spawn(async move {
// The same test that chose the dialog's copy chooses the
// action, because the copy is a promise. A daemon that
// advertises the handoff was described as replacing itself
// with nothing interrupted — if that fails, the failure is
// shown, not silently traded for the restart that kills
// every pane the user was just told would live. The
// stop-and-start path is only taken where its bloodbath is
// what the dialog actually said.
if crate::daemon::spawn::local_daemon_supports(
crate::daemon::protocol::FEATURE_HANDOFF,
) {
crate::daemon::spawn::hand_off()
} else {
crate::daemon::spawn::restart()
}
})
.await;
Self::settle_after_restart(this, restarted, cx).await;
})
.detach();
}
/// Put the window back together once the restart has been attempted, either
/// way it went.
///
/// Split into three steps because the middle one must not run with this
/// window's entity leased. `resync_after_local_daemon_change` takes the
/// whole `App` and rebuilds *every* local window from the tree, and the
/// first thing it asks each one is which tabs it is showing — which it gets
/// by reading that window's `Tty7App` back out of the registry. Called from
/// inside `update_in`, the window it reaches for first is the one already
/// leased to the closure, and gpui's answer to a double lease is a panic
/// that takes the process with it: clicking Restart Server made the whole
/// app vanish.
async fn settle_after_restart(
this: gpui::WeakEntity<Self>,
restarted: anyhow::Result<()>,
cx: &mut gpui::AsyncApp,
) {
// A refused handoff leaves the daemon exactly as it was, still serving
// the panes this window just dropped. Leaving it at the error here
// strands them: every restore path is tree-driven, and the next sync of
// this emptied window would diff into "close every tab" against the
// mirror — deleting the pane records under the still-running shells, or
// the whole workspace if the user simply closes the window first (#554).
// So the resync below runs either way; this step only says so on screen.
if this
.update_in(cx, |this, window, cx| {
if let Err(e) = &restarted {
log::error!("restart background service failed, resyncing from the tree: {e}");
let text = t_fmt(
L10nKey::AppRestartServerFailed,
&[("error", &e.to_string())],
);
this.startup_error = Some(gpui::SharedString::from(text.clone()));
window.push_notification(text, cx);
}
})
.is_err()
{
return;
}
// The link we held pointed at the server we just killed; the reconnect
// finds a new process whose registry knows nothing about these panes.
// The helper drops the dead link first — a pull sent down it dies on a
// dead socket before the reader notices — and rebuilds every local
// window from the tree. Where the restart failed and the daemon is
// really gone, the pull misses and the rehydration debt keeps the empty
// window from being pushed back up.
//
// The invalidating helper, not the one the reconnect uses: nothing here
// handshaked a link. Half of `hand_off`'s failures happen *after* the
// exec — a daemon that never started listening again is gone, and the
// client we still hold points at its socket, which `is_connected` keeps
// calling good until its reader sees the EOF.
let _ = cx.update(crate::ui::tree_sync::resync_after_local_daemon_change);
let _ = this.update_in(cx, |this, window, cx| {
this.focus_active(window, cx);
cx.notify();
});
}
fn set_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
let size = size.clamp(FONT_SIZE_MIN, FONT_SIZE_MAX);
self.font_size = size;
let px_size = px(size);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.font_size = px_size;
cx.notify();
});
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_size = size;
self.persist_settings_config(cx);
cx.notify();
}
pub(crate) fn change_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_font_size(self.font_size + delta, cx);
}
pub(crate) fn reset_font_size(&mut self, cx: &mut Context<Self>) {
self.set_font_size(Config::default().font_size, cx);
}
fn set_ui_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
use crate::core::config::{UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN};
let size = size.clamp(UI_FONT_SIZE_MIN, UI_FONT_SIZE_MAX);
let cfg = cx.global_mut::<Config>();
if cfg.ui_font_size == size {
return;
}
cfg.ui_font_size = size;
self.persist_settings_config(cx);
// Unlike the settings that only redraw the window they were changed
// in, this one re-lays-out every open window, and each reads the new
// rem from the global on its own next frame.
cx.refresh_windows();
cx.notify();
}
pub(crate) fn ui_font_size(&self, cx: &gpui::App) -> f32 {
cx.global::<Config>().ui_font_size
}
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_ui_font_size(self.ui_font_size(cx) + delta, cx);
}
pub(crate) fn reset_ui_font_size(&mut self, cx: &mut Context<Self>) {
self.set_ui_font_size(Config::default().ui_font_size, cx);
}
fn set_line_height(&mut self, mul: f32, cx: &mut Context<Self>) {
let mul = mul.clamp(LINE_HEIGHT_MIN, LINE_HEIGHT_MAX);
self.line_height = mul;
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.line_height_mul = mul;
cx.notify();
});
}
}
let cfg = cx.global_mut::<Config>();
cfg.line_height = mul;
self.persist_settings_config(cx);
cx.notify();
}
pub(crate) fn change_line_height(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_line_height(self.line_height + delta, cx);
}
pub(crate) fn reset_line_height(&mut self, cx: &mut Context<Self>) {
self.set_line_height(Config::default().line_height, cx);
}
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
if self.theme_draft_dirty() {
let id = id.to_string();
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
this.set_preset(&id, window, cx)
});
return;
}
// A confirmed pick ends any preview: there is nothing left to roll back.
self.theme_preview_restore = None;
self.write_preset(id, cx);
self.after_theme_change(window, cx);
}
/// Points whichever preset slot is live at `id`, in memory only.
fn write_preset(&mut self, id: &str, cx: &mut Context<Self>) {
let dark_now = crate::ui::theme::system_dark(cx);
let cfg = cx.global_mut::<Config>();
if !cfg.theme_follow_system {
cfg.theme_preset = id.to_string();
} else if dark_now {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
}
/// Shows a preset for as long as the palette's theme picker is open, so
/// arrowing through the list is how you find out what a theme looks like.
/// Nothing is written to `config.json` until the pick is confirmed.
pub(crate) fn preview_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
if self.theme_preview_restore.is_none() {
self.theme_preview_restore = Some(crate::ui::theme::effective_preset_id(cx));
}
self.write_preset(id, cx);
self.apply_theme_change(false, window, cx);
}
/// Puts back the preset that was live before the preview started. A no-op
/// when nothing is being previewed.
pub(crate) fn cancel_preset_preview(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.theme_preview_restore.take() else {
return;
};
self.write_preset(&id, cx);
self.apply_theme_change(false, window, cx);
}
pub(crate) fn set_slot_preset(
&mut self,
dark_slot: bool,
id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.theme_draft_dirty() {
let id = id.to_string();
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
this.set_slot_preset(dark_slot, &id, window, cx)
});
return;
}
let cfg = cx.global_mut::<Config>();
if dark_slot {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
self.after_theme_change(window, cx);
}
pub(crate) fn set_theme_follow_system(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.theme_draft_dirty() {
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
this.set_theme_follow_system(on, window, cx)
});
return;
}
if on {
let manual = cx.global::<Config>().theme_preset.clone();
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = true;
if manual_dark {
cfg.theme_preset_dark = manual;
} else {
cfg.theme_preset_light = manual;
}
} else {
let effective = crate::ui::theme::effective_preset_id(cx);
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = false;
cfg.theme_preset = effective;
}
self.after_theme_change(window, cx);
let slot = if on {
if crate::ui::theme::system_dark(cx) {
crate::ui::settings::ThemeSlot::Dark
} else {
crate::ui::settings::ThemeSlot::Light
}
} else {
crate::ui::settings::ThemeSlot::Manual
};
if let Some(s) = self.active_settings_mut() {
s.theme_panel_slot = slot;
}
}
pub(crate) fn set_theme_legible_palette(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().theme_legible_palette = on;
self.after_theme_change(window, cx);
}
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.apply_theme_change(true, window, cx);
}
/// Repaints everything a theme change touches. `persist` is false for a
/// palette preview, which repaints on every arrow key and must not turn
/// each of those keystrokes into a `config.json` write.
fn apply_theme_change(&mut self, persist: bool, window: &mut Window, cx: &mut Context<Self>) {
apply_theme(Some(window), cx);
set_menus(cx);
if persist {
self.persist_settings_config(cx);
}
self.rebuild_theme_editor(window, cx);
self.sync_window_opacity_slider(window, cx);
cx.notify();
}
/// Opens or closes the theme picker, and moves the caret with it.
///
/// The panel leads with a search box, and it opened unfocused — so the
/// first thing typed at a panel whose whole job is picking one of nine
/// themes went nowhere. Closing hands the caret back to the settings
/// search rather than leaving it on a box that is no longer drawn.
pub(crate) fn toggle_theme_panel(
&mut self,
slot: crate::ui::settings::ThemeSlot,
window: &mut Window,
cx: &mut Context<Self>,
) {
let opened = match self.active_settings_mut() {
Some(s) if s.theme_panel_open && s.theme_panel_slot == slot => {
s.theme_panel_open = false;
false
}
Some(s) => {
s.theme_panel_open = true;
s.theme_panel_slot = slot;
true
}
None => return,
};
self.focus_theme_panel(opened, window, cx);
cx.notify();
}
pub(crate) fn close_theme_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_settings_mut().is_none() {
return;
}
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = false;
}
self.focus_theme_panel(false, window, cx);
cx.notify();
}
fn focus_theme_panel(&mut self, opened: bool, window: &mut Window, cx: &mut Context<Self>) {
let handle = self.settings.as_ref().map(|s| match opened {
true => s.theme_search.read(cx).focus_handle(cx),
false => s.search.read(cx).focus_handle(cx),
});
if let Some(handle) = handle {
window.focus(&handle, cx);
}
}
pub(crate) fn open_themes_folder(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(dir) = crate::ui::presets::themes_dir() else {
log::warn!("no config directory, so no themes folder to open");
return;
};
// The folder is created on demand, so the first click on a fresh
// install is also the one that can fail. Handing an absent path to the
// file manager just opens nothing.
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("could not create {}: {e}", dir.display());
crate::ui::host_ops::HostOps::notify_err(
window,
cx,
&t_fmt(
L10nKey::OpenInFileManagerFailed,
&[("path", &dir.display().to_string())],
),
&e,
);
return;
}
cx.open_with_system(&dir);
}
pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
match crate::ui::presets::fork_to_file(&theme) {
Ok(new_id) => {
crate::ui::presets::load_registry(cx);
self.set_preset(&new_id, window, cx);
}
// A button that does nothing is the worst kind of failure: there
// is no way to tell it from "I clicked the wrong thing".
Err(e) => {
log::warn!("failed to duplicate theme: {e}");
crate::ui::host_ops::HostOps::notify_err(
window,
cx,
t(L10nKey::ThemeDuplicateFailed),
&e,
);
}
}
}
pub(crate) fn theme_draft_dirty(&self) -> bool {
self.active_settings()
.is_some_and(|s| s.theme_draft.is_some())
}
pub(crate) fn save_theme_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some((_, draft)) = self.active_settings().and_then(|s| s.theme_draft.clone()) else {
return true;
};
if let Err(error) = crate::ui::presets::write_theme_file(&draft) {
if let Some(s) = self.active_settings_mut() {
s.theme_draft_error = Some(error.to_string());
}
crate::ui::host_ops::HostOps::notify_err(
window,
cx,
t(L10nKey::ThemeSaveFailed),
&error,
);
return false;
}
if let Some(s) = self.active_settings_mut() {
s.theme_draft = None;
s.theme_draft_error = None;
}
cx.notify();
true
}
pub(crate) fn cancel_theme_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.theme_draft_error = None;
}
if let Some((original, _)) = self
.active_settings_mut()
.and_then(|s| s.theme_draft.take())
{
let mut themes = crate::ui::presets::all(cx);
if let Some(slot) = themes.iter_mut().find(|t| t.id == original.id) {
*slot = original;
}
cx.set_global(crate::ui::presets::Themes(themes));
apply_theme(Some(window), cx);
self.rebuild_theme_editor(window, cx);
cx.notify();
}
}
fn mutate_active_theme(
&mut self,
mutate: impl FnOnce(&mut crate::ui::presets::Theme),
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = crate::ui::theme::effective_preset_id(cx);
let mut theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
return;
}
mutate(&mut theme);
if let Some(state) = self.active_settings_mut() {
let original = state
.theme_draft
.as_ref()
.filter(|(original, _)| original.id == id)
.map(|(original, _)| original.clone())
.unwrap_or_else(|| crate::ui::presets::by_id(cx, &id));
if crate::ui::presets::to_yaml(&original) == crate::ui::presets::to_yaml(&theme) {
state.theme_draft = None;
} else {
state.theme_draft = Some((original, theme.clone()));
}
let mut themes = crate::ui::presets::all(cx);
if let Some(slot) = themes.iter_mut().find(|t| t.id == id) {
*slot = theme;
}
cx.set_global(crate::ui::presets::Themes(themes));
} else {
if let Err(error) = crate::ui::presets::write_theme_file(&theme) {
crate::ui::host_ops::HostOps::notify_err(
window,
cx,
t(L10nKey::ThemeSaveFailed),
&error,
);
return;
}
crate::ui::presets::load_registry(cx);
}
apply_theme(Some(window), cx);
cx.notify();
}
pub(crate) fn edit_active_theme(
&mut self,
edit: ThemeEdit,
value: gpui::Hsla,
window: &mut Window,
cx: &mut Context<Self>,
) {
let c = hsla_to_u32(value);
self.mutate_active_theme(
|theme| match edit {
ThemeEdit::Background => theme.background = Fill::Solid(c),
ThemeEdit::Foreground => theme.foreground = c,
ThemeEdit::Accent => theme.accent = c,
ThemeEdit::Cursor => theme.caret = Some(c),
ThemeEdit::Selection => theme.selection = Some(c),
ThemeEdit::Ansi(i) => theme.ansi16[i] = ((c >> 16) as u8, (c >> 8) as u8, c as u8),
},
window,
cx,
);
}
pub(crate) fn effective_window_opacity(cx: &App) -> f32 {
let config = cx.global::<Config>();
let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let blur = config.window_blur.unwrap_or(theme.blur);
config.window_opacity.or(theme.opacity).unwrap_or_else(|| {
crate::ui::theme::default_window_opacity(config.window_backdrop, blur)
})
}
pub(crate) fn set_window_opacity(
&mut self,
v: f32,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().window_opacity = Some(v.clamp(0.2, 1.0));
apply_theme(Some(window), cx);
self.persist_settings_config(cx);
cx.notify();
}
pub(crate) fn set_window_blur(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().window_blur = Some(on);
apply_theme(Some(window), cx);
self.persist_settings_config(cx);
cx.notify();
}
#[cfg(target_os = "windows")]
pub(crate) fn set_window_backdrop(
&mut self,
backdrop: WindowBackdrop,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().window_backdrop = backdrop;
apply_theme(Some(window), cx);
self.persist_settings_config(cx);
// A material changes the default opacity (SYSTEM_MATERIAL_OPACITY
// vs 1.0), so the slider must track the new effective value.
self.sync_window_opacity_slider(window, cx);
// Rebuild the rows as well as the selected index. The previous value
// may have been an unsupported preset retained only for cross-machine
// config sync, and must disappear after the user selects a supported
// preset on this machine.
self.sync_window_backdrop_select(window, cx);
cx.notify();
}
pub(crate) fn reset_window_overrides(&mut self, window: &mut Window, cx: &mut Context<Self>) {
{
let config = cx.global_mut::<Config>();
clear_window_override_values(config, cfg!(target_os = "windows"));
}
apply_theme(Some(window), cx);
self.persist_settings_config(cx);
self.sync_window_opacity_slider(window, cx);
#[cfg(target_os = "windows")]
self.sync_window_backdrop_select(window, cx);
cx.notify();
}
pub(crate) fn sync_window_opacity_slider(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let eff = Self::effective_window_opacity(cx);
if let Some(slider) = self
.active_settings()
.map(|s| s.window_opacity_slider.clone())
{
slider.update(cx, |s, cx| s.set_value(eff, window, cx));
}
}
#[cfg(target_os = "windows")]
pub(crate) fn sync_window_backdrop_select(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(select) = self
.active_settings()
.map(|s| s.window_backdrop_select.clone())
{
let current = cx.global::<Config>().window_backdrop;
let rows = window_backdrop_labels(current);
let selected = window_backdrop_index(current);
select.update(cx, |state, cx| {
state.set_items(SearchableVec::new(rows), window, cx);
// Replacing the delegate clears its selection snapshot, so
// restore the stored value after installing the new rows.
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
});
}
}
pub(crate) fn pick_theme_image(&mut self, cx: &mut Context<Self>) {
let rx = cx.prompt_for_paths(gpui::PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(paths))) = rx.await {
if let Some(path) = paths.into_iter().next() {
let _ = this.update_in(cx, |this, window, cx| {
this.mutate_active_theme(
|theme| {
let opacity =
theme.image.as_ref().map(|i| i.opacity).unwrap_or(0.3);
theme.image = Some(crate::ui::presets::Image { path, opacity });
},
window,
cx,
);
this.rebuild_theme_editor(window, cx);
});
}
}
})
.detach();
}
pub(crate) fn remove_theme_image(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.mutate_active_theme(|theme| theme.image = None, window, cx);
self.rebuild_theme_editor(window, cx);
}
pub(crate) fn set_theme_image_opacity(
&mut self,
v: f32,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mutate_active_theme(
|theme| {
if let Some(img) = theme.image.as_mut() {
img.opacity = v.clamp(0.0, 1.0);
}
},
window,
cx,
);
}
pub(crate) fn rebuild_theme_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.is_none() {
return;
}
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
if let Some(s) = self.settings.as_mut() {
s.theme_editor = None;
}
return;
}
let neutrals = theme.neutrals();
let seed_specs: [(ThemeEdit, u32); 5] = [
(ThemeEdit::Background, theme.background_color()),
(ThemeEdit::Foreground, theme.foreground),
(ThemeEdit::Accent, theme.accent),
(ThemeEdit::Cursor, theme.caret.unwrap_or(theme.accent)),
(ThemeEdit::Selection, neutrals.selection),
];
let mut subs = Vec::new();
let mut make =
|edit: ThemeEdit, value: u32, subs: &mut Vec<Subscription>, cx: &mut Context<Self>| {
let eff: gpui::Hsla = gpui::rgb(value).into();
let state = cx.new(|cx| ColorPickerState::new(window, cx).default_value(eff));
subs.push(cx.subscribe_in(
&state,
window,
move |this, _picker, ev: &ColorPickerEvent, window, cx| {
let ColorPickerEvent::Change(value) = ev;
if let Some(v) = value {
this.edit_active_theme(edit, *v, window, cx);
}
},
));
state
};
let seed = seed_specs
.iter()
.map(|&(edit, value)| (edit, make(edit, value, &mut subs, cx)))
.collect();
let ansi = (0..16)
.map(|i| {
let (r, g, b) = theme.ansi16[i];
let value = (r as u32) << 16 | (g as u32) << 8 | b as u32;
(
ThemeEdit::Ansi(i),
make(ThemeEdit::Ansi(i), value, &mut subs, cx),
)
})
.collect();
let image_opacity_slider = theme.image.as_ref().map(|img| {
let slider = cx.new(|_| {
SliderState::new()
.min(0.0)
.max(1.0)
.step(0.01)
.default_value(img.opacity)
});
subs.push(cx.subscribe_in(
&slider,
window,
|this, _s, ev: &SliderEvent, window, cx| {
if let SliderEvent::Change(v) = ev {
this.set_theme_image_opacity(v.start(), window, cx);
}
},
));
slider
});
if let Some(s) = self.settings.as_mut() {
s.theme_editor = Some(ThemeEditor {
for_id: theme.id.clone(),
seed,
ansi,
image_opacity_slider,
_subs: subs,
});
}
}
pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context<Self>) {
let features = on.then(|| {
crate::core::config::FontFeatures(Arc::new(vec![
("calt".to_string(), 1),
("liga".to_string(), 1),
]))
});
let gpui_features = features
.as_ref()
.map(crate::core::config::gpui_font_features);
self.font_features = gpui_features.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let features = gpui_features.clone();
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_features = features;
self.persist_settings_config(cx);
cx.notify();
}
fn apply_terminal_config_to_panes(&self, config: &Config, cx: &mut Context<Self>) {
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.terminal.apply_user_config(config);
cx.notify();
});
}
}
}
pub(crate) fn set_cursor_style(&mut self, style: ConfigCursorStyle, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.cursor_style = style);
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
pub(crate) fn persist_settings_config(&mut self, cx: &mut Context<Self>) {
let config = cx.global::<Config>().clone();
let error = config.try_save().err().map(|error| error.to_string());
if let Some(message) = &error {
log::warn!("failed to save settings: {message}");
}
if let Some(s) = self.active_settings_mut() {
if error.is_none() {
s.saved_config = config;
}
s.save_error = error;
}
cx.notify();
}
pub(crate) fn discard_unsaved_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let snapshot = self
.active_settings()
.filter(|s| s.save_error.is_some())
.map(|s| s.saved_config.clone());
if let Some(snapshot) = snapshot {
cx.set_global(snapshot);
if let Some(s) = self.active_settings_mut() {
s.save_error = None;
}
self.reload_from_config(window, cx);
}
}
pub(crate) fn update_config(
&mut self,
cx: &mut Context<Self>,
mutate: impl FnOnce(&mut Config),
) {
mutate(cx.global_mut::<Config>());
self.persist_settings_config(cx);
}
pub(crate) fn set_link_url(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.link_url = on);
}
pub(crate) fn set_link_file_open(
&mut self,
mode: crate::core::config::LinkFileOpen,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.link_file_open = Some(mode));
}
pub(crate) fn set_ssh_loopback_forward(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.ssh_loopback_forward = on);
}
pub(crate) fn set_verify_host_keys(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.verify_host_keys = on);
}
pub(crate) fn set_ssh_warn_on_close(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
}
pub(crate) fn forward_route(&self, pane_id: u64, cx: &gpui::App) -> ForwardRoute {
let workspace = self
.tabs
.iter()
.flat_map(|tab| tab.pane.terminals())
.find_map(|leaf| {
let view = leaf.read(cx);
(view.pane_id == pane_id).then(|| view.workspace().cloned())?
});
ForwardRoute { pane_id, workspace }
}
pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context<Self>) {
self.loopback_panel.managed = self.forward_route(pane_id, cx).list();
cx.notify();
}
pub(crate) fn set_managed_forward_kind(
&mut self,
kind: crate::daemon::protocol::SshForwardKind,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_kind = kind;
cx.notify();
}
/// The managed-forward form's fields as plain text, for the two callers
/// that have to agree on what they add up to.
pub(crate) fn managed_forward_fields(&self, cx: &gpui::App) -> ForwardFields {
let val = |input: &Entity<InputState>| input.read(cx).value().to_string();
ForwardFields {
advanced: self.loopback_panel.mf_advanced,
kind: self.loopback_panel.mf_kind,
bind_host: val(&self.loopback_panel.mf_bind_host),
bind_port: val(&self.loopback_panel.mf_bind_port),
target_host: val(&self.loopback_panel.mf_target_host),
target_port: val(&self.loopback_panel.mf_target_port),
description: val(&self.loopback_panel.mf_description),
}
}
pub(crate) fn add_managed_forward(
&mut self,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
let fields = self.managed_forward_fields(cx);
let Some(rule) = fields.collect() else {
// Add is disabled while the fields do not make a rule and the form
// already says what is missing, so there is nothing to do here and
// nothing left to explain.
return;
};
let route = self.forward_route(pane_id, cx);
let previous = self.loopback_panel.mf_editing.clone();
// A saved edit is a replace, and the rule being replaced has to come
// out first: the ordinary edit keeps the bind port, and the far side
// really does bind it, so adding first would collide with the very
// rule it is replacing and fail every edit that only renames a rule or
// moves its target.
if let Some(old) = &previous {
let Some(list) = route.remove(old.id) else {
// Nothing came back, so what the far side still has is
// unknown — most likely the old rule, still listening. Adding
// on top of that would collide with it, and putting it back
// afterwards would leave two of it. Stop while nothing has
// changed.
self.loopback_panel.mf_error = Some(t(L10nKey::ForwardRequestFailed).to_string());
cx.notify();
return;
};
self.loopback_panel.managed = list;
}
// The short form aims at the same number on both ends because that is
// the address people can predict. When it is already taken here, the
// useful answer is another port rather than a complaint: somebody who
// typed one number to forward one port has not been asked to care
// which local port it lands on, and the row says where it came out.
let retry_free_port = !fields.advanced && rule.bind_port != 0;
let mut failure = match self.place_forward(&route, rule.clone()) {
PlaceOutcome::Placed => None,
// Nobody answered, so nothing was bound and nothing would be bound
// by asking again — a second round trip would only spend another
// timeout on the way to the same sentence.
PlaceOutcome::Unreachable(msg) => Some(msg),
PlaceOutcome::Rejected(msg) if !retry_free_port => Some(msg),
PlaceOutcome::Rejected(_) => {
match self.place_forward(
&route,
crate::daemon::protocol::SshForwardRule {
bind_port: 0,
..rule.clone()
},
) {
PlaceOutcome::Placed => None,
PlaceOutcome::Rejected(msg) | PlaceOutcome::Unreachable(msg) => Some(msg),
}
}
};
if let Some(msg) = failure {
// Put back what the edit took out, so the worst a failed Save can
// do is leave everything exactly as it was — with the form still
// open on the rule and the reason underneath it.
if let Some(old) = &previous {
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
if let Some(list) = route.add(rule_of(old)) {
// The rule comes back under a new id and the form is still
// editing it, so the form has to be pointed at the entry
// that now exists — otherwise the next Save would remove
// an id nobody has and add a second copy of the rule.
if let Some(restored) = added_forward(&before, &list) {
self.loopback_panel.mf_editing = Some(restored.clone());
}
self.loopback_panel.managed = list;
}
}
self.loopback_panel.mf_error = Some(msg);
cx.notify();
return;
}
self.loopback_panel.mf_editing = None;
self.loopback_panel.mf_error = None;
self.loopback_panel.form_pane_id = None;
for input in [
&self.loopback_panel.mf_bind_port,
&self.loopback_panel.mf_target_host,
&self.loopback_panel.mf_target_port,
&self.loopback_panel.mf_description,
] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
cx.notify();
}
/// Ask the far side for one rule, and say what became of it.
///
/// A rule that could not be started is registered all the same, with the
/// reason in its status, so whether the add worked is a question about the
/// entry it appended rather than about whether the call returned. The dead
/// entry is taken back out — a forward listed as listening on nothing is
/// worse than no forward.
fn place_forward(
&mut self,
route: &ForwardRoute,
rule: crate::daemon::protocol::SshForwardRule,
) -> PlaceOutcome {
use crate::daemon::protocol::ForwardStatus;
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
// The request never got an answer. An empty list here is not "this
// pane has no forwards", it is "nobody said" — assigning it is what
// used to blank the panel on a dropped connection.
let Some(list) = route.add(rule) else {
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
};
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
ForwardStatus::Listening => None,
});
self.loopback_panel.managed = list;
let Some((id, msg)) = broken else {
return PlaceOutcome::Placed;
};
if let Some(list) = route.remove(id) {
self.loopback_panel.managed = list;
}
PlaceOutcome::Rejected(msg)
}
pub(crate) fn edit_managed_forward(
&mut self,
forward: crate::daemon::protocol::ManagedForward,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_kind = forward.kind;
self.loopback_panel.form_pane_id = Some(forward.pane_id);
self.loopback_panel.mf_error = None;
// A rule that already exists is shown whole: the short form cannot
// spell a bind host or a remote forward, so editing one through it
// would silently rewrite the parts it cannot see.
self.loopback_panel.mf_advanced = true;
let target_port = if forward.target_port == 0 {
String::new()
} else {
forward.target_port.to_string()
};
let fields: [(&Entity<InputState>, String); 5] = [
(&self.loopback_panel.mf_bind_host, forward.bind_host.clone()),
(
&self.loopback_panel.mf_bind_port,
forward.bind_port.to_string(),
),
(
&self.loopback_panel.mf_target_host,
forward.target_host.clone(),
),
(&self.loopback_panel.mf_target_port, target_port),
(
&self.loopback_panel.mf_description,
forward.description.clone().unwrap_or_default(),
),
];
for (input, value) in fields {
input.update(cx, |input, cx| input.set_value(&value, window, cx));
}
self.loopback_panel.mf_editing = Some(forward);
cx.notify();
}
pub(crate) fn cancel_managed_forward_edit(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_editing = None;
self.loopback_panel.mf_error = None;
for input in [
&self.loopback_panel.mf_bind_port,
&self.loopback_panel.mf_target_host,
&self.loopback_panel.mf_target_port,
&self.loopback_panel.mf_description,
] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
self.loopback_panel
.mf_bind_host
.update(cx, |input, cx| input.set_value("127.0.0.1", window, cx));
cx.notify();
}
pub(crate) fn remove_managed_forward(
&mut self,
pane_id: u64,
forward_id: u64,
cx: &mut Context<Self>,
) {
// Only what the far side actually answered with. A request that never
// got a reply knows nothing about the remaining forwards, and writing
// its empty list into the panel would blank a list that is still there.
if let Some(list) = self.forward_route(pane_id, cx).remove(forward_id) {
self.loopback_panel.managed = list;
}
cx.notify();
}
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Whatever the panel would let this pane forward, which is a wider set
// than "a connected native-ssh pane": a pane in a remote workspace
// forwards over the workspace's own connection, and the command used
// to do nothing at all there while the panel beside it worked.
let Some(ctx) = self.pane_forward_ctx(window, cx) else {
return;
};
if ctx.route.is_none() {
return;
}
self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx);
if self.loopback_panel.form_pane_id != Some(ctx.pane_id) {
self.toggle_managed_forward_form(ctx.pane_id, window, cx);
}
}
pub(crate) fn toggle_managed_forward_form(
&mut self,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.loopback_panel.form_pane_id == Some(pane_id) {
self.close_managed_forward_form(window, cx);
return;
}
self.loopback_panel.form_pane_id = Some(pane_id);
self.loopback_panel.mf_advanced = false;
self.loopback_panel.mf_kind = crate::daemon::protocol::SshForwardKind::Local;
self.cancel_managed_forward_edit(window, cx);
self.refresh_managed_forwards(pane_id, cx);
self.arm_managed_forward_form(pane_id, window, cx);
}
/// Opens the form focused and listening for Return.
///
/// It had neither. Every other form in the app opens with the caret in the
/// first field and answers Return — this one opened cold, so adding a rule
/// meant clicking into Bind first, and once you were there the only way to
/// commit was the mouse again. Escape did nothing either, which is handled
/// on the form itself in `forwards.rs`; a key event only reaches it while
/// something inside it holds focus, so the focus below is what makes that
/// work too.
fn arm_managed_forward_form(
&mut self,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
let inputs = [
self.loopback_panel.mf_bind_host.clone(),
self.loopback_panel.mf_bind_port.clone(),
self.loopback_panel.mf_target_host.clone(),
self.loopback_panel.mf_target_port.clone(),
self.loopback_panel.mf_description.clone(),
];
self.loopback_panel.mf_subs = inputs
.iter()
.map(|input| {
cx.subscribe_in(
input,
window,
move |this, _input, ev: &InputEvent, window, cx| {
if let InputEvent::PressEnter { .. } = ev {
// A no-op when the fields do not make a rule yet:
// `add_managed_forward` already guards on that and
// the form already says what is missing.
this.add_managed_forward(pane_id, window, cx);
}
},
)
})
.collect();
inputs[0].update(cx, |s, cx| s.focus(window, cx));
}
pub(crate) fn toggle_managed_forward_advanced(&mut self, cx: &mut Context<Self>) {
self.loopback_panel.mf_advanced = !self.loopback_panel.mf_advanced;
self.loopback_panel.mf_error = None;
cx.notify();
}
pub(crate) fn close_managed_forward_form(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let was_open = self.loopback_panel.form_pane_id.take().is_some();
self.loopback_panel.mf_subs.clear();
self.cancel_managed_forward_edit(window, cx);
if was_open {
// The form held the focus, so taking it down has to hand it back —
// otherwise the next keystroke goes nowhere until the user clicks.
self.focus_active(window, cx);
}
}
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
match parse_ssh_connect_input(input) {
Ok(parsed) => {
let (profile, proxy_jump) =
match ssh_config::resolve_alias_to_profile(&parsed.profile.host) {
Some(resolved) => {
let mut p = resolved.profile;
if !parsed.profile.user.is_empty() {
p.user = parsed.profile.user;
}
if parsed.profile.port != 22 {
p.port = parsed.profile.port;
}
if !parsed.profile.identity_files.is_empty() {
p.identity_files = parsed.profile.identity_files;
}
(p, parsed.proxy_jump.or(resolved.proxy_jump))
}
None => (parsed.profile, parsed.proxy_jump),
};
let verify = cx.global::<Config>().verify_host_keys;
let spec = crate::ui::ssh_connect::native_spec_from_transient_profile(
&profile,
proxy_jump,
&crate::core::keychain::OsCredentialStore,
verify,
&crate::ui::ssh_connect::config_alias_resolver,
);
self.open_native_ssh_tab(Box::new(spec), window, cx);
}
Err(reason) => self.push_ssh_connect_error(reason, cx),
}
}
pub(crate) fn set_check_for_updates(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.check_for_updates = on);
}
pub(crate) fn set_auto_download_updates(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.auto_download_updates = on);
}
/// Moves this installation to another release feed. See
/// `update::switch_channel` for what a switch invalidates, and why moving
/// back to Stable does not roll the running build back.
pub(crate) fn set_update_channel(
&mut self,
channel: crate::core::config::UpdateChannel,
cx: &mut Context<Self>,
) {
if cx.global::<Config>().update_channel == channel {
return;
}
self.update_config(cx, |cfg| cfg.update_channel = channel);
crate::core::update::switch_channel(cx);
}
/// Takes effect at next launch: `core::cli_install` runs once from `main`,
/// before there is a window to flip this in. Turning it off does not remove
/// a symlink already placed — the install is idempotent, not reversible.
pub(crate) fn set_install_cli_on_path(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.install_cli_on_path = on);
}
pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
}
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.cursor_blink = on);
if !on {
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.cursor_visible = true;
cx.notify();
});
}
}
}
}
pub(crate) fn set_scrollback_limit(&mut self, lines: usize, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.scrollback_limit = lines.clamp(100, crate::core::config::MAX_SCROLLBACK)
});
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
pub(crate) fn set_new_tab_position(&mut self, pos: NewTabPosition, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.new_tab_position = pos);
}
pub(crate) fn set_tab_bar_position(&mut self, pos: TabBarPosition, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.tab_bar_position = pos);
}
pub(crate) fn set_sidebar_grouping(
&mut self,
grouping: crate::core::config::SidebarGrouping,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping);
}
pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on);
}
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<Config>().tab_bar_position {
TabBarPosition::Top => TabBarPosition::Left,
TabBarPosition::Left => TabBarPosition::Top,
};
self.set_tab_bar_position(next, cx);
}
pub(crate) fn toggle_left_panel(&mut self, cx: &mut Context<Self>) {
let (pos, collapsed) = match cx.global::<Config>().tab_bar_position {
TabBarPosition::Top => (TabBarPosition::Left, false),
TabBarPosition::Left => (TabBarPosition::Left, !self.sidebar_collapsed),
};
self.sidebar_collapsed = collapsed;
self.update_config(cx, |cfg| {
cfg.tab_bar_position = pos;
cfg.sidebar_collapsed = collapsed;
});
cx.notify();
}
pub(crate) fn left_panel_open(&self, cx: &gpui::App) -> bool {
matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.sidebar_collapsed
&& !self.tabs.is_empty()
}
pub(crate) fn set_notify_mode(
&mut self,
mode: crate::core::config::NotifyMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.notify_on_command_finish = mode);
}
pub(crate) fn set_notify_threshold(&mut self, secs: u64, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.notify_threshold_secs = secs.clamp(1, 3600));
}
pub(crate) fn set_bell_mode(
&mut self,
mode: crate::core::config::BellMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.bell = mode);
}
pub(crate) fn set_restore_session(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.restore_session = on);
}
/// Takes effect on the next pane: a shell is told where its history lives
/// when it starts, and nothing can move it afterwards.
pub(crate) fn set_per_pane_history(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.per_pane_history = on);
}
pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.show_tray_icon = on);
}
pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.macos_option_as_alt = on);
}
pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on);
crate::ui::theme::apply_cursor_hide_mode(cx);
}
pub(crate) fn set_focus_follows_mouse(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.focus_follows_mouse = on);
}
pub(crate) fn set_mouse_reporting(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.mouse_reporting = on);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.report_mouse = on;
cx.notify();
});
}
}
}
pub(crate) fn set_mouse_scroll_multiplier(&mut self, mult: f32, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.mouse_scroll_multiplier = mult.clamp(0.1, 10.0)
});
}
pub(crate) fn set_smooth_scroll(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.smooth_scroll = on);
}
/// Panes read the modifier off the global config as each wheel event
/// arrives, so there is nothing to push at them here.
pub(crate) fn set_mouse_zoom_modifier(
&mut self,
modifier: MouseZoomModifier,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.mouse_zoom_modifier = modifier);
}
pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on);
}
pub(crate) fn set_copy_on_select(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.copy_on_select = on);
}
pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.smart_select = on);
}
/// Hands the prompt to the shell's own line editor, or takes it back.
///
/// Live panes carry a cached copy of the flag (see
/// [`crate::terminal::view::TerminalView::prompt_editor`]), so the switch
/// has to reach them: without this, only panes opened afterwards would
/// change hands.
pub(crate) fn set_prompt_editor(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.prompt_editor = on);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| v.set_prompt_editor(on, cx));
}
}
}
pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.tab_completion = on);
}
pub(crate) fn set_history_search(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.history_search = on);
}
pub(crate) fn set_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.startup_mode = mode);
}
pub(crate) fn set_remember_window_size(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.remember_window_size = on);
}
pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) {
let title = WorkspaceStore::all(cx)
.get(self.workspace)
.filter(|w| crate::ui::machine_mirror::pane_count(cx, w).unwrap_or(0) > 0)
.and_then(|w| crate::ui::machine_mirror::display_name(cx, w))
.unwrap_or_else(|| "tty7".to_string());
if *self.window_title.borrow() == title {
return;
}
window.set_window_title(&title);
*self.window_title.borrow_mut() = title;
}
pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) {
self.sync_window_title(window, cx);
if let Some(settings) = self.settings.as_ref() {
window.focus(&settings.focus_handle, cx);
return;
}
let Some(tab) = self.tabs.get(self.active) else {
window.focus(&self.home_focus, cx);
return;
};
if let Some(overlay) = tab.diff_overlay.as_ref() {
window.focus(&overlay.focus_handle, cx);
return;
}
if let Some(leaf) = tab.focus_target() {
let handle = leaf.focus_handle(cx);
window.focus(&handle, cx);
}
}
/// Sample which pane holds focus right now and record it against the
/// active tab.
///
/// A sample only ever writes the truth, but it can only write it when
/// there is one to read: with focus one handle off the panes it finds no
/// leaf and leaves the field alone. That is why it is no longer the only
/// writer (see [`Tty7App::remember_focused_leaf`]) — it stays because the
/// callers below want the answer settled at a named moment, before a pane
/// is detached or a tab is torn down and the layout stops being able to
/// answer at all.
pub(crate) fn remember_active_pane(&mut self, window: &Window, cx: &App) {
let active = self.active;
if let Some(tab) = self.tabs.get_mut(active) {
if let Some(leaf) = tab.pane.focused_leaf(window, cx) {
tab.last_focused = Some(leaf.entity_id());
}
}
}
/// Record `leaf` as the pane its tab comes back to, as focus arrives in it.
///
/// Sampling at switch time asks which leaf holds focus *at that instant*,
/// and by then focus is routinely somewhere else: the switcher's own
/// search input, a palette that just closed, the tab strip, a pane
/// restored and never clicked. The sample then wrote nothing and the tab
/// kept a stale pane — or the `None` it was born with — and came back to
/// its first leaf instead of the one the reader was working in (#843).
///
/// Focus-in is the one moment that knows the answer without having to
/// guess when to look, so it is the primary writer now. The tab is found
/// by the leaf rather than assumed to be the active one: a pane dragged
/// into another tab is focused after the move, and it is the tab holding
/// it now that has to remember it.
pub(crate) fn remember_focused_leaf(&mut self, leaf: gpui::EntityId) {
remember_leaf_in(&mut self.tabs, leaf);
}
/// Toggle fullscreen, and say how to leave it on the way in.
///
/// Only on the way in, and only from the action: entering is an instant in
/// which the window buttons disappear, and a window that starts fullscreen
/// because the setting says so is not a surprise anybody needs explaining.
/// The chord comes from the keymap rather than from a string, because it is
/// `F11` on Windows and Linux, `Cmd+Enter` on macOS, and either of them may
/// have been rebound.
///
/// The hint carries an id of its own, which is what keeps a held-down
/// `F11` to one notice rather than a column of identical ones: pushing
/// under an id already on screen replaces that one. Leaving through the
/// action takes it back too, so a quick in-and-out does not leave the way
/// out on screen after it has been taken. Leaving some other way — a
/// window manager with a chord of its own — just lets it time out, which
/// is a second or two of a stale notice and not worth watching every
/// frame for.
fn toggle_fullscreen(&self, window: &mut Window, cx: &mut App) {
let entering = !window.is_fullscreen();
window.toggle_fullscreen();
window.remove_notification::<FullscreenHint>(cx);
// Nothing disappeared where there were no buttons to begin with, so
// there is nothing to explain.
if !entering || !window_controls_drawn(false) {
return;
}
let hint = match crate::ui::home::key_hint("ToggleFullscreen", cx) {
Some(chord) => t_fmt(L10nKey::AppFullscreenEntered, &[("key", &chord)]),
// Rebound to nothing at all: still worth saying the buttons are gone,
// just without naming a key that would not work.
None => t(L10nKey::AppFullscreenEnteredNoKey).to_string(),
};
window.push_notification(
gpui_component::notification::Notification::new()
.id::<FullscreenHint>()
.message(hint),
cx,
);
}
fn focus_leaf(&self, leaf: &PaneSlot, window: &mut Window, cx: &mut App) {
let handle = leaf.focus_handle(cx);
window.focus(&handle, cx);
}
fn land_pane(
&mut self,
slot_id: gpui::EntityId,
pending: &Entity<crate::ui::pending_pane::PendingPane>,
parts: Result<crate::terminal::view::ShellParts, String>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Self>,
) {
let parts = match parts {
Ok(parts) => parts,
Err(reason) => {
pending.update(cx, |p, cx| p.fail(reason, cx));
return;
}
};
let still_there = self
.tabs
.iter()
.any(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == slot_id));
if !still_there {
log::info!(
"pane {} arrived after its slot closed; killing it",
parts.pane_id
);
let route = crate::terminal::PaneRoute::for_workspace(parts.workspace.as_ref());
kill_pane_off_thread(route, parts.pane_id, cx);
return;
}
let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx);
let resume = (!parts.restored)
.then(|| {
let spawn = &pending.read(cx).spawn;
agent_resume_command(
&spawn.agent,
spawn.agent_session_id.as_deref(),
spawn.agent_launch_argv.as_deref(),
cx,
)
})
.flatten();
let view = build_terminal_view(parts, font_size, window, cx);
if let Some(cmd) = resume {
view.read(cx).run_command_line(&cmd);
}
let slot = PaneSlot::Ready(view.clone());
replace_leaf_in(&mut self.tabs, slot_id, slot.clone());
if was_focused {
self.focus_leaf(&slot, window, cx);
}
self.save_session(cx);
cx.notify();
}
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) {
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
}
}
fn new_tab_insert_at(&self, cx: &App) -> usize {
match cx.global::<Config>().new_tab_position {
NewTabPosition::AfterCurrent => (self.active + 1).min(self.tabs.len()),
NewTabPosition::End => self.tabs.len(),
}
}
pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.new_tab_with_shell(None, window, cx);
}
pub(crate) fn new_tab_at(
&mut self,
cwd: std::path::PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_tab_with_cwd(Some(cwd), None, window, cx);
}
/// Opens a new local shell in `cwd`, then types a command only after the
/// pane exists. LaunchServices uses this for a script/executable selected
/// in Finder and for `x-man-page:` requests.
///
/// A tab that did not open takes the command with it. `new_tab_with_cwd`
/// returns early when the spawn fails or the workspace cannot host a local
/// shell, and writing anyway would type the command into whatever pane was
/// focused before — a shell the user is mid-line in, or an agent.
pub(crate) fn new_tab_running(
&mut self,
cwd: std::path::PathBuf,
command: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let before = self.tabs.len();
self.new_tab_with_cwd(Some(cwd), None, window, cx);
if self.tabs.len() == before {
log::warn!("no tab opened for {command:?}; not writing it to another pane");
return;
}
if let Some(terminal) = self.focused_leaf(window, cx) {
terminal.read(cx).run_command_line(&command);
}
}
/// Uses the terminal that a newly-created window already opened. This keeps
/// a cold LaunchServices request to one tab rather than creating the
/// window's default shell and then a second shell for the requested item.
pub(crate) fn run_in_active_terminal(&self, command: &str, window: &Window, cx: &App) {
if let Some(terminal) = self.focused_leaf(window, cx) {
terminal.read(cx).run_command_line(command);
}
}
pub(crate) fn new_tab_with_shell(
&mut self,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
self.new_tab_with_cwd(cwd, shell, window, cx);
}
/// A shell taken out of the new-tab menu, wherever the ⌥ key said to put
/// it.
pub(crate) fn open_shell(
&mut self,
shell: Option<ShellSpec>,
at: SpawnWhere,
window: &mut Window,
cx: &mut Context<Self>,
) {
match at {
SpawnWhere::NewTab => self.new_tab_with_shell(shell, window, cx),
SpawnWhere::Split => {
self.split_into(Axis::Horizontal, Some(SpawnAs::Shell(shell)), window, cx)
}
}
}
fn new_tab_with_cwd(
&mut self,
cwd: Option<std::path::PathBuf>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_local_spawn(window, cx) {
return;
}
let group = self.spawn_group(cwd.as_deref(), cx);
let tab = match new_terminal(
self.window_workspace(cx),
Some(self.workspace),
self.font_size,
cwd,
None,
shell,
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("new tab spawn failed: {e}");
let text = t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]);
// A retry from the home screen fails the same way; keep the
// reason on screen rather than only in a toast that leaves.
self.startup_error = Some(gpui::SharedString::from(text.clone()));
window.push_notification(text, cx);
cx.notify();
return;
}
};
// Something opened, so whatever the last failure was is stale.
self.startup_error = None;
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
let new_tab = Tab::new(Pane::leaf(tab));
if let Some(group) = group {
*new_tab.sidebar_group.borrow_mut() = group;
}
self.tabs.insert(insert_at, new_tab);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
pub(crate) fn open_native_ssh_tab(
&mut self,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
});
let view = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH spawn failed: {e}");
window.push_notification(
t_fmt(
L10nKey::AppSshConnectionFailed,
&[("error", &e.to_string())],
),
cx,
);
return;
}
};
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs
.insert(insert_at, Tab::new(Pane::leaf(PaneSlot::Ready(view))));
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
pub(crate) fn respawn_native_ssh_in_place(
&mut self,
dead: &Entity<TerminalView>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = dead.read(cx).cwd();
let fresh = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH respawn failed: {e}");
window.push_notification(
t_fmt(L10nKey::AppSshReconnectFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
replace_leaf_in(
&mut self.tabs,
dead.entity_id(),
PaneSlot::Ready(fresh.clone()),
);
self.maximized = None;
self.focus_leaf(&PaneSlot::Ready(fresh), window, cx);
self.save_session(cx);
cx.notify();
}
pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) {
self.split_into(axis, None, window, cx);
}
/// `spawn` of `None` is what ⌘D has always done: copy the pane being split
/// — same shell, or the same host dialled again. A `Some` names the new
/// pane outright, which is how a row taken out of the new-tab menu with ⌥
/// held lands beside the current pane instead of in a tab of its own.
pub(crate) fn split_into(
&mut self,
axis: Axis,
spawn: Option<SpawnAs>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
if !self.guard_local_spawn(window, cx) {
return;
}
let cwd = target.read(cx).spawnable_cwd();
let spawn = match spawn {
Some(spawn) => spawn,
// A stored spec is resolved against the saved host before it is
// dialled; one handed in by a caller was just built from that host
// and needs no second pass.
None => match target.read(cx).ssh_spec() {
Some(spec) => {
SpawnAs::Ssh(crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx))
}
None => SpawnAs::Shell(target.read(cx).shell_spec()),
},
};
let new = match spawn {
SpawnAs::Ssh(spec) => {
match new_terminal_native(self.font_size, cwd, spec, window, cx) {
Ok(view) => PaneSlot::Ready(view),
Err(e) => {
log::error!("native SSH split spawn failed: {e}");
window.push_notification(
t_fmt(
L10nKey::AppSshConnectionFailed,
&[("error", &e.to_string())],
),
cx,
);
return;
}
}
}
SpawnAs::Shell(shell) => {
match new_terminal(
self.window_workspace(cx),
Some(self.workspace),
self.font_size,
cwd,
None,
shell,
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("split spawn failed: {e}");
window.push_notification(
t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
}
}
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab
.pane
.split_leaf(target.entity_id(), axis, false, new.clone())
{
self.maximized = None;
self.focus_leaf(&new, window, cx);
self.save_session(cx);
cx.notify();
}
}
}
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.close_pane_inner(false, window, cx);
}
/// `confirmed` is set only by the answer to this pane's own close question,
/// so it travels with the close rather than being read back off shared
/// state a second, unrelated close could have overwritten.
fn close_pane_inner(&mut self, confirmed: bool, window: &mut Window, cx: &mut Context<Self>) {
if !confirmed && let Some(reason) = self.focused_pane_close_reason(window, cx) {
self.ask_before_closing(CloseTarget::Pane, reason, window, cx);
return;
}
self.maximized = None;
let focused = self.tabs.get(self.active).and_then(|tab| {
tab.pane
.leaves()
.into_iter()
.find(|l| l.contains_focused(window, cx))
.and_then(|l| l.terminal().cloned())
});
let outcome = match self.tabs.get_mut(self.active) {
Some(tab) => tab.pane.close_focused(window, cx),
None => return,
};
match outcome {
// The last pane takes its tab with it. The question was already
// asked about this very pane, so carry the answer across rather
// than letting the tab re-derive the same reason and ask again.
CloseOutcome::RemoveSelf => {
self.close_tab_inner(self.active, confirmed, window, cx);
}
CloseOutcome::NotFound => {
let single = self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
if single {
self.close_tab_inner(self.active, confirmed, window, cx);
}
}
CloseOutcome::Collapsed => {
if let Some(leaf) = &focused {
kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx);
}
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
}
}
fn on_child_exited(
&mut self,
view: Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = view.entity_id();
let Some(index) = self
.tabs
.iter()
.position(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == id))
else {
return;
};
if view.read(cx).ssh_disconnected() {
cx.notify();
return;
}
match self.tabs[index].pane.close_leaf(view.entity_id()) {
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
CloseOutcome::NotFound => {}
CloseOutcome::Collapsed => {
kill_pane_off_thread(view.read(cx).pane_route(), view.read(cx).pane_id, cx);
if index == self.active {
self.maximized = None;
self.focus_active(window, cx);
}
self.save_session(cx);
cx.notify();
}
}
}
fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let leaves = match self.tabs.get(self.active) {
Some(tab) => tab.pane.leaves(),
None => return,
};
if leaves.len() < 2 {
return;
}
self.maximized = None;
let current = leaves
.iter()
.position(|l| l.contains_focused(window, cx))
.unwrap_or(0);
let next = if forward {
(current + 1) % leaves.len()
} else {
(current + leaves.len() - 1) % leaves.len()
};
let leaf = leaves[next].clone();
self.focus_leaf(&leaf, window, cx);
cx.notify();
}
fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.tabs.get(self.active) else {
return;
};
let Some(from) = tab.pane.focused_leaf(window, cx) else {
return;
};
let back = tab.focus_origin(from.entity_id(), dir);
let Some(target) = tab.pane.neighbor_in_dir(dir, back, window, cx) else {
return;
};
let live: Vec<gpui::EntityId> = tab.pane.leaves().iter().map(|l| l.entity_id()).collect();
let (from, to) = (from.entity_id(), target.entity_id());
let active = self.active;
if let Some(tab) = self.tabs.get_mut(active) {
tab.remember_focus_origin(from, to, dir, &live);
}
self.maximized = None;
self.focus_leaf(&target, window, cx);
cx.notify();
}
fn resize_pane(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
let changed = self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.resize_focused_pane(dir, RESIZE_STEP, window, cx));
if changed {
self.save_session(cx);
cx.notify();
}
}
fn swap_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let (from, len) = match self.tabs.get(self.active) {
Some(tab) => (tab.pane.focused_index(window, cx), tab.pane.leaves().len()),
None => return,
};
if len < 2 {
return;
}
let from = from.unwrap_or(0);
let to = if forward {
(from + 1) % len
} else {
(from + len - 1) % len
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.swap_leaf_indices(from, to) {
self.maximized = None;
self.save_session(cx);
cx.notify();
}
}
}
/// The patch of the layout a pane being dragged would land on, lit up.
///
/// Also records that landing as the one a drop would take, so the drop and
/// the highlight can never disagree: a zone the tree refuses to carry out
/// is neither drawn nor remembered, and releasing over it does nothing.
fn pane_landing(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
use crate::ui::pane_drag;
let from = pane_drag::lifted(&self.pane_drag)?;
let area = self.pane_area.get()?;
let tab = self.tabs.get(self.active)?;
let leaves = tab.pane.leaves();
let slot = leaves.iter().find(|l| l.entity_id() == from)?;
let bounds = pane_drag::leaf_bounds(&tab.pane, area);
let zone = pane_drag::zone_at(area, &bounds, window.mouse_position())?;
// The zone comes back naming its target by position, which only means
// anything against this frame's leaves. Drawn against the panes here,
// and remembered as the panes so the drop that reads it back a frame
// later is looking for the same ones.
let here = zone.map(|i| leaves.get(i).cloned())?;
let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?;
let rect = pane_drag::landing(&tab.pane, slot, here, area)?;
pane_drag::set_landing(&self.pane_drag, pinned);
let accent = cx.theme().drag_border;
Some(
div()
.absolute()
.left(rect.origin.x - area.origin.x)
.top(rect.origin.y - area.origin.y)
.w(rect.size.width)
.h(rect.size.height)
.rounded(px(6.))
.border_2()
.border_color(accent)
.bg(accent.opacity(0.15))
.into_any_element(),
)
}
/// The patch of the layout a tab held over it would be grafted into, lit
/// up — and, while that is on offer, the strip held still underneath it.
///
/// A tab is dragged with the same grip that reorders it, so the two
/// readings share one gesture: over the strip or the sidebar it is a
/// reorder, out over the panes it is a merge. Suspending the reorder is
/// what keeps the drop from being both.
fn tab_landing(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
self.tab_merge.set(None);
let dragged = crate::ui::reorder::dragged_tab(&self.reorder);
let offer = dragged.and_then(|id| self.tab_landing_rect(id, window));
crate::ui::reorder::suspend(&self.reorder, offer.is_some());
let (zone, rect) = offer?;
let area = self.pane_area.get()?;
self.tab_merge.set(Some((dragged?, zone)));
let accent = cx.theme().drag_border;
Some(
div()
.absolute()
.left(rect.origin.x - area.origin.x)
.top(rect.origin.y - area.origin.y)
.w(rect.size.width)
.h(rect.size.height)
.rounded(px(6.))
.border_2()
.border_color(accent)
.bg(accent.opacity(0.15))
.into_any_element(),
)
}
/// Where the tab `id` would land in the tab on screen, if it can land there
/// at all — it cannot land in itself, and there is nothing to land in while
/// a pane is zoomed over the layout.
fn tab_landing_rect(
&self,
id: tty7_core::core::machine::TabId,
window: &Window,
) -> Option<(
crate::ui::pane_drag::DropZone<gpui::EntityId>,
Bounds<Pixels>,
)> {
use crate::ui::pane_drag;
if self.maximized.is_some() {
return None;
}
let area = self.pane_area.get()?;
let host = self.tabs.get(self.active)?;
if host.tree_id.get() == id {
return None;
}
let sub = &self.tabs.iter().find(|t| t.tree_id.get() == id)?.pane;
let leaves = host.pane.leaves();
let bounds = pane_drag::leaf_bounds(&host.pane, area);
let zone = pane_drag::tab_zone_at(area, &bounds, window.mouse_position())?;
// Named by pane rather than by position for the drop a frame later,
// the same way a pane drag's landing is.
let here = zone.map(|i| leaves.get(i).cloned())?;
let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?;
let rect = pane_drag::graft_landing(&host.pane, sub, here, area)?;
Some((pinned, rect))
}
/// Merges a dragged tab into the tab on screen, where the last painted
/// frame said it would go.
///
/// The panes come across as they were arranged, and the tab they came from
/// goes away with them — nothing is killed and nothing is respawned, so a
/// shell mid-command carries on through the move. What the source tab held
/// besides panes is the tab's own: its name goes, and its overlays come
/// along only where the tab receiving them has none of its own to lose.
fn merge_tab(
&mut self,
source: tty7_core::core::machine::TabId,
zone: crate::ui::pane_drag::DropZone<gpui::EntityId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pane_hover.set(None);
let Some(from) = self.tabs.iter().position(|t| t.tree_id.get() == source) else {
return;
};
let Some(host) = self.tabs.get(self.active) else {
return;
};
if host.tree_id.get() == source {
return;
}
let leaves = host.pane.leaves();
let Some(zone) = zone.map(|id| leaves.iter().find(|l| l.entity_id() == id).cloned()) else {
return;
};
let mut moved = self.tabs.remove(from);
if from < self.active {
self.active -= 1;
}
// Lifted out rather than cloned: these are the live terminals, and a
// graft that finds nowhere to put them has to be able to hand them
// back intact.
let sub = std::mem::replace(&mut moved.pane, crate::ui::pane::Pane::Empty);
let first = sub.first_leaf();
let grafted = match self.tabs.get_mut(self.active) {
Some(host) => crate::ui::pane_drag::graft(&mut host.pane, sub, zone),
None => Err(sub),
};
if let Err(sub) = grafted {
moved.pane = sub;
self.tabs.insert(from, moved);
if from <= self.active {
self.active += 1;
}
return;
}
let host = &mut self.tabs[self.active];
if host.code.is_none() {
host.code = moved.code;
}
if host.diff_overlay.is_none() {
host.diff_overlay = moved.diff_overlay;
}
if self
.renaming
.as_ref()
.is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab))
{
self.renaming = None;
}
self.maximized = None;
if let Some(leaf) = first {
self.focus_leaf(&leaf, window, cx);
}
self.save_session(cx);
cx.notify();
}
/// The caret between two tabs where a pane carried up to the strip — or
/// out to the sidebar — would become a tab of its own.
///
/// The reverse of grafting a tab in, and read the same way: offered only
/// when the drop would change something, which for a detach means the pane
/// has somewhere to leave. The last pane in a tab is already a tab, so
/// nothing lights up for it.
fn detach_caret(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
self.pane_detach.set(None);
let pane = crate::ui::pane_drag::lifted(&self.pane_drag)?;
let tab = self.tabs.get(self.active)?;
if tab.pane.leaves().len() < 2 {
return None;
}
if !tab.pane.leaves().iter().any(|l| l.entity_id() == pane) {
return None;
}
// The pane's own landing is read later in the frame and answers nothing
// while the pointer is up here, so the two are never both on offer;
// the drop takes this one first regardless.
let (at, caret) = self.detach_slot(window, cx)?;
self.pane_detach.set(Some((pane, at)));
let accent = cx.theme().drag_border;
Some(
div()
.absolute()
.left(caret.origin.x)
.top(caret.origin.y)
.w(caret.size.width)
.h(caret.size.height)
.rounded_full()
.bg(accent)
.into_any_element(),
)
}
/// Which gap between tabs the pointer is in, as the tab a newcomer would
/// be inserted before and the caret marking it — on the strip, or on the
/// sidebar, whichever the pointer is over.
///
/// `viewport` and the pointer are both in the window's outer coordinates.
fn detach_slot(&self, window: &Window, cx: &App) -> Option<(usize, Bounds<Pixels>)> {
/// How far above its first row the sidebar's band starts, so the gap
/// over that row is inside it.
const BAND_REACH: f32 = 6.;
let pointer = window.mouse_position();
// The two surfaces never share a window: the sidebar *is* the tab bar
// when it is up, and the strip is what stands in for it when it is not.
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
let viewport = window.viewport_size();
let pad = gpui_component::window_paddings(window);
// The band each surface claims, read off the tabs it drew rather than
// measured as an element of its own: the chips and the rows are the
// only part of either surface a drop has anything to say about, and a
// band derived from them cannot disagree with the gaps measured inside
// it. Stretched past the outermost tab so the empty space beyond —
// where the strip keeps its New Tab button, where the sidebar keeps
// nothing at all — still reads as "after everything".
let band = |axis: Axis, drawn: &[(usize, Bounds<Pixels>)]| {
let top = drawn.iter().map(|(_, b)| b.origin.y).reduce(Pixels::min)?;
let left = drawn.iter().map(|(_, b)| b.origin.x).reduce(Pixels::min)?;
let right = drawn
.iter()
.map(|(_, b)| b.origin.x + b.size.width)
.reduce(Pixels::max)?;
Some(match axis {
Axis::Horizontal => strip_band(viewport, pad),
Axis::Vertical => Bounds {
origin: point(left, top - px(BAND_REACH)),
size: size(
right - left,
(viewport.height - top + px(BAND_REACH)).max(px(0.)),
),
},
})
};
// Sorted by where they were drawn rather than by tab: the sidebar
// groups its rows, and a strip that has scrolled shows a window of
// chips. What the pointer is between is a matter of the screen.
let measured = |slots: &[Bounds<Pixels>]| -> Vec<(usize, Bounds<Pixels>)> {
slots
.iter()
.enumerate()
.filter(|(_, b)| b.size.width > px(0.) && b.size.height > px(0.))
.map(|(i, b)| (i, *b))
.collect()
};
let surfaces = [
(!vertical).then(|| (Axis::Horizontal, measured(&self.strip_slots.borrow()))),
self.sidebar_open(cx)
.then(|| (Axis::Vertical, measured(&self.sidebar_slots.borrow()))),
];
let (axis, mut drawn) = surfaces
.into_iter()
.flatten()
.find(|(axis, drawn)| band(*axis, drawn).is_some_and(|b| b.contains(&pointer)))?;
let lead = |b: &Bounds<Pixels>| match axis {
Axis::Horizontal => b.origin.x,
Axis::Vertical => b.origin.y,
};
drawn.sort_by(|(_, a), (_, b)| lead(a).as_f32().total_cmp(&lead(b).as_f32()));
let row: Vec<Bounds<Pixels>> = drawn.iter().map(|(_, b)| *b).collect();
let (gap, caret) = crate::ui::pane_drag::insertion(&row, axis, pointer)?;
// The gap counts tabs on screen; what an insert needs is a place in the
// list. Past the last tab drawn is the end of the list, which is not
// the same thing as the last tab drawn plus one: a strip too narrow to
// show every chip has tabs on either side of the ones it drew.
let at = drawn.get(gap).map(|(i, _)| *i).unwrap_or(self.tabs.len());
Some((at, caret))
}
/// Takes a dragged pane out of its tab and gives it one of its own, where
/// the last painted frame's caret said.
///
/// Nothing is spawned and nothing is killed: the pane that leaves is the
/// same pane, still running whatever it was running.
fn detach_pane(
&mut self,
pane: gpui::EntityId,
at: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pane_hover.set(None);
// Which pane the tab being left comes back to, settled while the one
// that is leaving is still in it to be ruled out.
self.remember_active_pane(window, cx);
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
};
let Some(slot) = tab
.pane
.leaves()
.into_iter()
.find(|l| l.entity_id() == pane)
else {
return;
};
// Refused for the last pane in a tab, which is what makes a drop that
// would change nothing do nothing.
let Some(slot) = tab.pane.take_leaf(&slot) else {
return;
};
let cwd = slot
.terminal()
.and_then(|view| view.read(cx).spawnable_cwd());
let group = self.spawn_group(cwd.as_deref(), cx);
let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot));
if let Some(group) = group {
*fresh.sidebar_group.borrow_mut() = group;
}
let at = at.min(self.tabs.len());
self.tabs.insert(at, fresh);
self.maximized = None;
self.active = at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Puts a dragged pane down where the last painted frame said it would go.
///
/// Both ends of the drop are named by pane rather than by position, so a
/// pane that closed between the frame that offered the landing and this one
/// leaves the drop with nothing to land against, and it is refused.
fn drop_pane(
&mut self,
from: gpui::EntityId,
zone: crate::ui::pane_drag::DropZone<gpui::EntityId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pane_hover.set(None);
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
};
let leaves = tab.pane.leaves();
let here = |id| leaves.iter().find(|l| l.entity_id() == id).cloned();
let (Some(moved), Some(zone)) = (here(from), zone.map(here)) else {
return;
};
if !crate::ui::pane_drag::apply(&mut tab.pane, &moved, zone) {
return;
}
self.maximized = None;
self.focus_leaf(&moved, window, cx);
self.save_session(cx);
cx.notify();
}
/// Activates the tab carrying `id`. A workspace this window just switched
/// to hydrates its tabs asynchronously, so when the tab is not here yet the
/// request is parked and claimed on the frame it arrives.
pub(crate) fn activate_tree_tab(
&mut self,
id: tty7_core::core::machine::TabId,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.tabs.iter().position(|t| t.tree_id.get() == id) {
Some(index) => self.activate(index, window, cx),
None => self.pending_tab = Some(id),
}
}
fn claim_pending_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(want) = self.pending_tab else {
return;
};
let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == want) else {
return;
};
self.pending_tab = None;
self.activate(index, window, cx);
}
/// Declare to the pane registry which terminals are on screen this
/// frame: the active tab's, nobody else's. Stated per frame rather than
/// maintained at every tab operation, the way `scm_sync_watchers` is.
/// Entity ids only — reading the entities here would put them into the
/// window's tracked set, which is exactly what the registry routes
/// around.
fn declare_displayed_panes(&self, cx: &App) {
let active = self.active;
crate::terminal::view::declare_displayed(
cx,
self.tabs.iter().enumerate().flat_map(|(i, tab)| {
tab.pane
.leaves()
.into_iter()
.filter_map(move |slot| Some((slot.terminal()?.entity_id(), i == active)))
}),
);
}
/// Stamps whichever tab is active right now. Called once per frame rather
/// than from the ten places that assign `self.active` — it is idempotent,
/// so the stamp only advances on the first frame after a switch.
pub(crate) fn touch_active_tab(&self) {
let Some(tab) = self.tabs.get(self.active) else {
return;
};
let top = self.tab_use_seq.get();
if top != 0 && tab.last_used.get() == top {
return;
}
let next = top + 1;
self.tab_use_seq.set(next);
tab.last_used.set(next);
}
/// Tab indices most-recently-used first. The active tab always leads, even
/// before its own stamp lands; tabs never activated trail in strip order.
pub(crate) fn tabs_by_mru(&self) -> Vec<usize> {
let stamps: Vec<u64> = self.tabs.iter().map(|t| t.last_used.get()).collect();
mru_order(&stamps, self.active)
}
/// The neighbouring tab in the order the strip shows them, wrapping at
/// the ends — no switcher, no MRU (#867). "Shows" matters with the
/// sidebar grouping tabs: the next row is not always the next index.
fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let order = self.visual_tab_order(cx);
if let Some(next) = step_in_order(&order, self.active, forward) {
self.activate(next, window, cx);
}
}
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if index < self.tabs.len() && index != self.active {
self.remember_active_pane(window, cx);
// Zoom rides with its tab (#599): stash the outgoing tab's zoom
// and bring the incoming tab's back. The clears elsewhere (drag,
// split, close) still stand — those genuinely reshape what was
// zoomed; merely looking at another tab does not.
if let Some(outgoing) = self.tabs.get_mut(self.active) {
outgoing.zoomed = self.maximized.take();
}
self.active = index;
self.maximized = self.tabs[index].zoomed.take().filter(|leaf| {
// The zoomed pane may have exited while its tab was away —
// a stale entity must not come back as the zoom.
self.tabs[index]
.pane
.leaves()
.iter()
.any(|l| l.entity_id() == leaf.entity_id())
});
self.maybe_refresh_diff_overlay(cx);
self.sidebar_scroll.scroll_to_item(index);
if self.code_panel_visible() {
self.file_tree_refresh_roots(window, cx);
self.file_tree.focus_handle.focus(window, cx);
} else {
self.focus_active(window, cx);
}
self.save_session(cx);
cx.notify();
}
}
/// Whether tab `index` has a pane zoomed over hidden siblings — what the
/// chrome marks so the state is readable without toggling it (#752).
///
/// Zoom rides with its tab (#599): the active tab's lives in
/// `self.maximized`, every other tab's is parked in `Tab::zoomed`. Either
/// can name a pane that exited while nobody was looking, which is why the
/// answer is asked of the layout rather than of the handle alone.
pub(crate) fn tab_is_zoomed(&self, index: usize) -> bool {
let Some(tab) = self.tabs.get(index) else {
return false;
};
let zoom = match index == self.active {
true => self.maximized.as_ref(),
false => tab.zoomed.as_ref(),
};
zoom.is_some_and(|zoom| {
tab.pane
.zoom_hides_siblings(|slot| slot.entity_id() == zoom.entity_id())
})
}
/// Zoom the pane being worked in over its siblings, or put the layout back.
///
/// Both directions name the pane outright rather than leaving it to
/// whatever holds focus at the instant the toggle lands (#869). Un-zooming
/// hands the tab the pane that was zoomed as its answer before focusing it:
/// that pane was the only one on screen to work in, and the tab's memory is
/// written by focus-in alone, which nothing promises has caught up. Zooming
/// with focus off the panes — a palette just closed, the tab strip — takes
/// the pane the tab remembers, the one switching back to it would focus,
/// and not whichever leaf the layout happens to list first.
fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(leaf) = self.maximized.take() {
remember_leaf_in(&mut self.tabs, leaf.entity_id());
self.focus_active(window, cx);
cx.notify();
return;
}
let Some(tab) = self.tabs.get(self.active) else {
return;
};
if tab.pane.leaves().len() < 2 {
return;
}
let leaf = tab
.pane
.focused_leaf(window, cx)
.or_else(|| tab.focus_target())
.and_then(|slot| slot.terminal().cloned());
if let Some(leaf) = leaf {
let handle = leaf.read(cx).focus_handle.clone();
self.maximized = Some(leaf);
window.focus(&handle, cx);
cx.notify();
}
}
pub(crate) fn close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
self.close_tab_inner(index, false, window, cx);
}
/// See [`Self::close_pane_inner`] for what `confirmed` carries.
fn close_tab_inner(
&mut self,
index: usize,
confirmed: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if index >= self.tabs.len() {
return;
}
if !confirmed && let Some(reason) = self.tab_close_reason(index, cx) {
let id = self.tabs[index].tree_id.get();
self.ask_before_closing(CloseTarget::Tab(id), reason, window, cx);
return;
}
self.maximized = None;
let worktree_cwd = self.tab_host_cwd(index, window, cx);
let snapshot = tab_to_session(&self.tabs[index], cx);
self.closed.push(snapshot);
if self.closed.len() > MAX_CLOSED_TABS {
self.closed.remove(0);
}
for leaf in self.tabs[index].pane.terminals() {
kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx);
}
self.tabs.remove(index);
// Only losing the renaming tab itself ends the rename — closing an
// unrelated tab must not throw the half-typed name away (#598).
if self
.renaming
.as_ref()
.is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab))
{
self.renaming = None;
}
if self.tabs.is_empty() {
self.active = 0;
} else if self.active >= self.tabs.len() {
self.active = self.tabs.len() - 1;
} else if index < self.active {
self.active -= 1;
}
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
self.offer_worktree_cleanup(worktree_cwd, cx);
}
fn offer_worktree_cleanup(
&mut self,
cwd: Option<(crate::ui::host_ops::SharedHost, std::path::PathBuf)>,
cx: &mut Context<Self>,
) {
let Some((host, cwd)) = cwd else { return };
let id = host.id();
let open_cwds: Vec<std::path::PathBuf> = self
.tabs
.iter()
.flat_map(|tab| tab.pane.terminals())
.filter_map(|leaf| {
let view = leaf.read(cx);
(view.host_id() == id).then(|| view.host_cwd())?
})
.collect();
let remove_host = host.clone();
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
crate::core::worktree::managed(h, &cwd)
.filter(|wt| !crate::core::worktree::occupied(h, &wt.path, &open_cwds))
},
move |_this, found, cx| {
let Some(wt) = found else { return };
let path = wt.path.display().to_string();
let detail = if wt.dirty {
t_fmt(L10nKey::AppWorktreeRemoveDetailDirty, &[("path", &path)])
} else {
t_fmt(L10nKey::AppWorktreeRemoveDetailClean, &[("path", &path)])
};
let title = t_fmt(L10nKey::AppWorktreeRemoveTitle, &[("branch", &wt.branch)]);
let level = if wt.dirty {
PromptLevel::Warning
} else {
PromptLevel::Info
};
let remove_label = if wt.dirty {
t(L10nKey::AppWorktreeDiscardAndRemove)
} else {
t(L10nKey::AppWorktreeRemove)
};
cx.spawn(async move |this, cx| {
let Ok(answer) = this.update_in(cx, |_, window, cx| {
window.prompt(
level,
&title,
Some(&detail),
&crate::ui::confirm_answers(remove_label, t(L10nKey::AppWorktreeKeep)),
cx,
)
}) else {
return;
};
if !matches!(answer.await, Ok(0)) {
return;
}
let force = wt.dirty;
let branch = wt.branch.clone();
let _ = this.update_in(cx, |_, window, cx| {
crate::ui::host_ops::HostOps::run_in(
remove_host,
window,
cx,
move |h| crate::core::worktree::remove(h, &wt, force),
move |_this, result, window, cx| match result {
Ok(()) => window.push_notification(
t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]),
cx,
),
Err(e) => window.push_notification(
t_fmt(
L10nKey::AppWorktreeRemoveFailed,
&[("error", &e.to_string())],
),
cx,
),
},
);
});
})
.detach();
},
);
}
pub(crate) fn close_other_tabs(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if index >= self.tabs.len() {
return;
}
// A bulk close skips the tabs whose profile asked to be warned about,
// and closes the rest outright — one dialog per tab is not a question
// anyone can answer, and only the first would ever get asked. It
// deliberately does *not* skip merely busy tabs: on a working window
// that is most of them, and a menu item that quietly closes nothing is
// worse than one that closes what it says.
for i in (0..self.tabs.len()).rev() {
if i == index || self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab_inner(i, true, window, cx);
}
}
pub(crate) fn close_tabs_right_of(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Same bargain as `close_other_tabs`.
for i in ((index + 1)..self.tabs.len()).rev() {
if self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab_inner(i, true, window, cx);
}
}
pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) {
use crate::core::cli_agent::AgentStatus;
let Some(tab) = self.tabs.get(index) else {
return;
};
let refocus = (index == self.active).then(|| tab.focus_target()).flatten();
for leaf in tab.pane.terminals() {
let refocus_incoming =
refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id());
leaf.update(cx, |view, cx| {
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
view.mark_agent_result_unread(refocus_incoming, cx);
cx.notify();
}
});
}
cx.notify();
}
/// A tab's working directory, spelled for the clipboard.
///
/// A cwd on this machine is re-spelled with this OS's separators, because
/// what goes on the clipboard is meant to be pasted into a shell and a
/// mixed-separator path is not one Windows will take. A remote pane's cwd
/// keeps the spelling its own machine uses — it is already native over
/// there. Both "Copy working directory" entry points (the app-menu action
/// and the tab context menu) come through here so the two cannot drift.
pub(crate) fn tab_cwd_text(&self, index: usize, window: &Window, cx: &App) -> Option<String> {
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
let view = leaf.read(cx);
let cwd = view.cwd()?;
Some(match view.local_cwd().is_some() {
true => crate::ui::path_display::native_separators(&cwd)
.display()
.to_string(),
false => cwd.display().to_string(),
})
}
pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context<Self>) {
if let Some(text) = self.tab_cwd_text(self.active, window, cx) {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
}
}
pub(crate) fn tab_agent_session(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<(Entity<TerminalView>, TabAgentSession)> {
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
let view = leaf.read(cx);
let agent = view.agent()?;
let session = TabAgentSession {
fork_label: agent.fork_label(),
session_id: view.agent_session().and_then(|s| s.session_id),
remote: view.remote_context().is_some(),
};
Some((leaf, session))
}
pub(crate) fn copy_agent_session_id(
&mut self,
index: usize,
window: &Window,
cx: &mut Context<Self>,
) {
if let Some(id) = self
.tab_agent_session(index, window, cx)
.and_then(|(_, s)| s.session_id)
{
cx.write_to_clipboard(gpui::ClipboardItem::new_string(id));
}
}
pub(crate) fn fork_agent_session(
&mut self,
index: usize,
source: Entity<TerminalView>,
placement: ForkPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(cmd) = self.agent_fork_command(&source, window, cx) else {
return;
};
if matches!(placement, ForkPlacement::Split { .. }) {
self.activate(index, window, cx);
}
let (cwd, shell) = {
let view = source.read(cx);
(view.local_cwd(), view.shell_spec())
};
let group = self.spawn_group(cwd.as_deref(), cx);
let new = match new_terminal(
self.window_workspace(cx),
Some(self.workspace),
self.font_size,
cwd,
None,
shell,
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("fork spawn failed: {e}");
window.push_notification(
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
let Some(terminal) = new.terminal() else {
log::error!("fork spawn produced a pane that is still connecting");
window.push_notification(t(L10nKey::AppForkStillConnecting), cx);
return;
};
terminal.read(cx).run_command_line(&cmd);
match placement {
ForkPlacement::NewTab => {
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
let tab = Tab::new(Pane::leaf(new));
if let Some(group) = group {
*tab.sidebar_group.borrow_mut() = group;
}
self.tabs.insert(insert_at, tab);
self.active = insert_at;
self.focus_active(window, cx);
}
ForkPlacement::Split { axis, before } => {
let placed = self.tabs.get_mut(index).is_some_and(|tab| {
tab.pane
.split_leaf(source.entity_id(), axis, before, new.clone())
});
if !placed {
return;
}
self.maximized = None;
self.focus_leaf(&new, window, cx);
}
}
self.save_session(cx);
cx.notify();
}
pub(crate) fn fork_active_pane_session(
&mut self,
placement: ForkPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(source) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
self.fork_agent_session(self.active, source, placement, window, cx);
}
pub(crate) fn fork_focused_pane_session(
&mut self,
axis: Axis,
before: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.fork_active_pane_session(ForkPlacement::Split { axis, before }, window, cx);
}
fn agent_fork_command(
&self,
source: &Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<String> {
use crate::core::cli_agent::AgentStatus;
let view = source.read(cx);
let (agent, session, remote) = (view.agent(), view.agent_session(), view.remote_context());
let Some(agent) = agent else {
window.push_notification(t(L10nKey::AppPaneNoCodingAgent), cx);
return None;
};
let name = agent.display_name();
if agent.fork_label().is_none() {
window.push_notification(t_fmt(L10nKey::AppForkNoCommand, &[("name", &name)]), cx);
return None;
}
if remote.is_some() {
window.push_notification(t_fmt(L10nKey::AppForkLocalOnly, &[("name", &name)]), cx);
return None;
}
let session = session.unwrap_or_default();
let Some(id) = session.session_id.as_deref() else {
window.push_notification(t_fmt(L10nKey::AppForkNoSessionId, &[("name", &name)]), cx);
return None;
};
let Some(cmd) = agent.fork_command(id, session.launch_argv.as_deref()) else {
window.push_notification(
t_fmt(L10nKey::AppForkSessionIdNotToken, &[("name", &name)]),
cx,
);
return None;
};
if session.status == AgentStatus::Working {
window.push_notification(t_fmt(L10nKey::AppForkMidTurn, &[("name", &name)]), cx);
}
Some(cmd)
}
pub(crate) fn check_for_updates_now(&mut self, window: &mut Window, cx: &mut Context<Self>) {
crate::core::update::spawn_check_forced(cx);
self.open_settings_section(SettingsSection::About, window, cx);
}
fn tab_host_cwd(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<(crate::ui::host_ops::SharedHost, std::path::PathBuf)> {
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
let view = leaf.read(cx);
Some((view.host(cx)?, view.host_cwd()?))
}
pub(crate) fn tab_is_in_repo(&self, index: usize, window: &Window, cx: &App) -> bool {
let Some(leaf) = self
.tabs
.get(index)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return false;
};
let view = leaf.read(cx);
let Some(cwd) = view.git_status_cwd() else {
return false;
};
cx.try_global::<crate::terminal::git_status::GitStatusCache>()
.and_then(|cache| cache.known_repo_for(view.host_id(), cwd))
.flatten()
.is_some()
}
pub(crate) fn new_worktree_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((host, cwd)) = self.tab_host_cwd(index, window, cx) else {
window.push_notification(t(L10nKey::AppTabNoWorkingDirectory), cx);
return;
};
let sheet_host = host.clone();
let probe_cwd = cwd.clone();
crate::ui::host_ops::HostOps::run_in(
host,
window,
cx,
move |h| crate::core::worktree::defaults(h, &probe_cwd),
move |this, result, window, cx| match result {
Ok(defaults) => this.open_worktree_prompt(sheet_host, cwd, defaults, window, cx),
Err(e) => window.push_notification(
t_fmt(L10nKey::AppNewWorktreeFailed, &[("error", &e.to_string())]),
cx,
),
},
);
}
pub(crate) fn open_worktree_tab(
&mut self,
wt: crate::core::worktree::NewWorktree,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = match new_terminal(
self.window_workspace(cx),
Some(self.workspace),
self.font_size,
Some(wt.path),
None,
None,
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("worktree tab spawn failed: {e}");
window.push_notification(
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
let mut tab = Tab::new(Pane::leaf(view));
tab.name = Some(wt.branch);
self.tabs.insert(insert_at, tab);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context<Self>) {
if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) {
return;
}
// The rename box rides out a reorder: it tracks its tab by tree id,
// so the drift that once forced it closed here is gone (#598).
let was_active = self.active;
let mut slots: Vec<Option<Tab>> = std::mem::take(&mut self.tabs)
.into_iter()
.map(Some)
.collect();
self.tabs = order.iter().filter_map(|&i| slots[i].take()).collect();
self.active = order.iter().position(|&i| i == was_active).unwrap_or(0);
self.save_session(cx);
cx.notify();
}
/// Opens a rename box on the current name, selected and focused, so the
/// first thing typed replaces it.
pub(crate) fn rename_box(
current: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let input = crate::ui::prefill::filled_box(current, window, cx);
input.update(cx, |state, cx| state.focus(window, cx));
input
}
pub(crate) fn start_rename(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.tabs.get(index).is_none() {
return;
}
let current = self.tab_label(&self.tabs[index], index, Some(&*window), cx);
let prefill = current.to_string();
let input = Self::rename_box(current, window, cx);
let subs = vec![cx.subscribe_in(
&input,
window,
|this, _input, ev: &InputEvent, window, cx| match ev {
InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
_ => {}
},
)];
self.renaming = Some(Renaming {
tab: self.tabs[index].tree_id.get(),
input,
prefill,
_subs: subs,
});
cx.notify();
}
pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let current =
crate::ui::machine_mirror::display_name_for(cx, self.workspace).unwrap_or_default();
let input = Self::rename_box(current, window, cx);
let subs = vec![cx.subscribe_in(
&input,
window,
|this, _input, ev: &InputEvent, window, cx| match ev {
InputEvent::PressEnter { .. } | InputEvent::Blur => {
this.commit_workspace_rename(window, cx)
}
_ => {}
},
)];
self.workspace_rename = Some(WorkspaceRename { input, _subs: subs });
cx.notify();
}
pub(crate) fn commit_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(rename) = self.workspace_rename.take() else {
return;
};
let value = rename.input.read(cx).value().trim().to_string();
let id = self.workspace;
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
crate::ui::windows::refresh_menu(cx);
self.sync_window_title(window, cx);
self.focus_active(window, cx);
cx.notify();
}
fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(renaming) = self.renaming.take() else {
return;
};
let value = renaming.input.read(cx).value();
match rename_outcome(&value, &renaming.prefill) {
Rename::Unchanged => {
self.focus_active(window, cx);
cx.notify();
return;
}
outcome => {
if let Some(tab) = self
.tabs
.iter_mut()
.find(|t| t.tree_id.get() == renaming.tab)
{
tab.name = match outcome {
Rename::Named(name) => Some(name),
_ => None,
};
}
}
}
self.save_session(cx);
crate::ui::windows::refresh_menu(cx);
self.focus_active(window, cx);
cx.notify();
}
pub(crate) fn palette_commands(&self, window: &Window, cx: &App) -> Vec<Command> {
let mut commands = Command::base_commands(
cx,
ChromeState {
rail_collapsed: self.sidebar_collapsed,
right_panel_visible: self.right_panel_visible,
document_filled: self.document_layout(cx)
== crate::core::config::DocumentLayout::Fill,
remote_server: WorkspaceStore::remote_ref(cx, self.workspace)
.filter(|remote| remote.target.hosts_our_server())
.map(|remote| crate::ui::remote_connect::route_label(cx, &remote)),
},
);
// Offered only where it would do something. A connection opened from a
// saved host has nothing to save, and a pane that is not an SSH one has
// no connection at all — either would be a row that quietly did nothing
// (#549).
if self.unsaved_ssh_session(window, cx).is_some() {
commands.push(
Command::localized(
L10nKey::CmdSshSaveConnection,
CommandKind::SaveSshSessionAsHost,
)
.with_subtitle(t(L10nKey::CmdSshSaveConnectionSubtitle))
.in_group(CommandGroup::Ssh),
);
}
for p in crate::ui::ssh_connect::ssh_profiles_by_frecency(cx) {
let subtitle = crate::core::ssh_profile::to_connect_string(&p);
let title = if p.name.is_empty() {
subtitle.clone()
} else {
p.name.clone()
};
commands.push(
Command::new(
t_fmt(L10nKey::AppCmdSshProfileTitle, &[("title", &title)]),
CommandKind::ConnectSavedProfile(p.id),
)
.with_subtitle(subtitle)
.in_group(CommandGroup::Ssh),
);
}
for (i, tab) in self.tabs.iter().enumerate() {
if i == self.active {
continue;
}
let label = self.tab_label(tab, i, None, cx);
commands.push(
Command::new(
t_fmt(L10nKey::AppCmdSwitchToTab, &[("label", &label)]),
CommandKind::ActivateTab(i),
)
.in_group(CommandGroup::TabsPanes),
);
}
commands
}
fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.palette.is_some() {
self.close_palette(window, cx);
return;
}
self.open_palette("", window, cx);
}
/// Open the palette with `query` already in its search field.
///
/// Unlike [`Self::toggle_palette`] this always opens. A row that names
/// what it will show cannot also be the thing that dismisses it, and the
/// only ways in here are rows like that.
pub(crate) fn open_palette(
&mut self,
query: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let commands = self.palette_commands(window, cx);
let view = cx.new(|cx| PaletteView::seeded(commands, query, window, cx));
self.palette_sub = Some(cx.subscribe_in(&view, window, Self::on_palette_event));
self.palette = Some(view);
cx.notify();
}
fn on_palette_event(
&mut self,
_view: &Entity<PaletteView>,
ev: &PaletteEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match ev {
PaletteEvent::Confirm(kind) => {
let kind = kind.clone();
// The picker is already showing this theme; keep it through the
// close instead of reverting and re-applying it.
if matches!(kind, CommandKind::SetTheme(_)) {
self.theme_preview_restore = None;
}
self.close_palette(window, cx);
self.run_command(kind, window, cx);
}
PaletteEvent::Dismiss => self.close_palette(window, cx),
PaletteEvent::PreviewTheme(i) => {
if let Some(id) = crate::ui::presets::all(cx).get(*i).map(|t| t.id.clone()) {
self.preview_preset(&id, window, cx);
}
}
PaletteEvent::CancelThemePreview => self.cancel_preset_preview(window, cx),
}
}
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.palette = None;
self.palette_sub = None;
// A previewed theme was never persisted: closing the palette any way
// other than confirming the pick puts the old one back.
self.cancel_preset_preview(window, cx);
self.focus_active(window, cx);
cx.notify();
}
fn focused_leaf(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> {
self.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
}
fn bump_command_frecency(&mut self, kind: &CommandKind, cx: &mut Context<Self>) {
let Some(id) = kind.id() else { return };
self.update_config(cx, |cfg| {
let entry = cfg.command_frecency.entry(id.to_string()).or_default();
entry.count = entry.count.saturating_add(1);
entry.last_used = crate::core::config::unix_now();
});
}
fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context<Self>) {
use CommandKind::*;
self.bump_command_frecency(&kind, cx);
match kind {
NewTab => self.new_tab(window, cx),
NewWorkspace => self.open_workspace_form(window, cx),
NewWindow => self.new_window(cx),
OpenWorkspacePicker => self.open_switcher(window, cx),
StopWorkspace => self.stop_workspace(self.workspace, window, cx),
DeleteWorkspace => self.delete_workspace(self.workspace, window, cx),
CloseWindow => self.close_window(window, cx),
SplitRight => self.split(Axis::Horizontal, window, cx),
SplitDown => self.split(Axis::Vertical, window, cx),
ClosePane => self.close_pane(window, cx),
NextPane => self.cycle_pane(true, window, cx),
PrevPane => self.cycle_pane(false, window, cx),
FocusPaneLeft => self.focus_pane_dir(Dir::Left, window, cx),
FocusPaneRight => self.focus_pane_dir(Dir::Right, window, cx),
FocusPaneUp => self.focus_pane_dir(Dir::Up, window, cx),
FocusPaneDown => self.focus_pane_dir(Dir::Down, window, cx),
ResizePaneLeft => self.resize_pane(Dir::Left, window, cx),
ResizePaneRight => self.resize_pane(Dir::Right, window, cx),
ResizePaneUp => self.resize_pane(Dir::Up, window, cx),
ResizePaneDown => self.resize_pane(Dir::Down, window, cx),
SwapPaneNext => self.swap_pane(true, window, cx),
SwapPanePrev => self.swap_pane(false, window, cx),
SelectNextTab => self.cycle_tab(true, window, cx),
SelectPrevTab => self.cycle_tab(false, window, cx),
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => self.toggle_fullscreen(window, cx),
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
ToggleLeftPanel => self.toggle_left_panel(cx),
ToggleRightPanel => self.toggle_right_panel(cx),
ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx),
ResetFontSize => self.reset_font_size(cx),
FindInTerminal => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.open_search(window, cx));
}
}
FindNext => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.find_step(true, cx));
}
}
FindPrevious => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.find_step(false, cx));
}
}
ClearTerminal => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.clear_scrollback(cx));
}
}
CopyText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| {
view.copy_contextual(false, cx);
});
}
}
CutText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| {
view.cut_contextual(cx);
});
}
}
PasteText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.paste_from_clipboard(cx));
}
}
SelectAllText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.select_all_contextual(cx));
}
}
ReopenClosedTab => self.reopen_closed_tab(window, cx),
RenameTab => self.start_rename(self.active, window, cx),
NewWorktreeTab => self.new_worktree_tab(self.active, window, cx),
CloseOtherTabs => self.close_other_tabs(self.active, window, cx),
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
MarkTabUnread => self.mark_tab_unread(self.active, cx),
ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx),
CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx),
RenameWorkspace => self.start_workspace_rename(window, cx),
OpenSettings => self.toggle_settings(window, cx),
ShowKeyboardShortcuts => {
self.open_settings_section(SettingsSection::Keybindings, window, cx)
}
About => self.open_settings_section(SettingsSection::About, window, cx),
CheckForUpdates => self.check_for_updates_now(window, cx),
OpenDocumentation => cx.open_url(DOCS_URL),
OpenDiscord => cx.open_url(DISCORD_URL),
ReportIssue => cx.open_url(ISSUES_URL),
// Same exit as the tray's: confirm, then stop the server with the
// app. A bare quit would leave the daemon orphaned behind a dead
// tray icon.
Quit => self.quit_stop_sessions(window, cx),
RestartDaemon => self.restart_window_daemon(window, cx),
UpdateLocalServer => self.update_local_server(window, cx),
UpdateRemoteServer => self.update_window_remote_server(window, cx),
ToggleSftp => self.toggle_sftp(window, cx),
ShowSshForwards => self.show_ssh_forwards(window, cx),
ToggleCodePanel => self.toggle_code_panel(window, cx),
ToggleDocumentFill => self.toggle_document_fill(cx),
DocumentWidthThird => {
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
}
DocumentWidthHalf => {
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
}
DocumentWidthTwoThirds => {
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
}
RestartSshSession => self.restart_ssh_session(window, cx),
SetTheme(i) => {
if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) {
self.set_preset(&id, window, cx);
}
}
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx),
QuickConnect(target) => {
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
self.quick_connect(qc, window, cx);
}
}
SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx),
SaveSshSessionAsHost => self.save_ssh_session_as_host(window, cx),
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
SendSelectionToAgent => self.send_selection_to_agent(window, cx),
SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
ScmCommit => self.run_scm_action(ScmIntent::Commit, window, cx),
ScmStageAll => self.run_scm_action(ScmIntent::StageAll, window, cx),
ScmUnstageAll => self.run_scm_action(ScmIntent::UnstageAll, window, cx),
ScmDiscardAll => self.run_scm_action(ScmIntent::DiscardAll, window, cx),
ScmPush => self.run_scm_action(ScmIntent::Push, window, cx),
ScmPull => self.run_scm_action(ScmIntent::Pull, window, cx),
ScmFetch => self.run_scm_action(ScmIntent::Fetch, window, cx),
ScmSync => self.run_scm_action(ScmIntent::Sync, window, cx),
ScmCreateBranch => self.run_scm_action(ScmIntent::CreateBranch, window, cx),
OpenBranchPicker => self.run_scm_action(ScmIntent::CheckoutBranch, window, cx),
ToggleDiffViewMode => self.toggle_diff_view_mode(cx),
OpenThemePicker | OpenSshConnectInput => {}
ActivateTab(i) => self.activate(i, window, cx),
}
}
pub(crate) fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some();
if let Some(tab) = self.tabs.get(self.active)
&& let Some(leaf) = tab.pane.terminals().into_iter().find(runs_agent)
{
return Some(leaf);
}
self.tabs
.iter()
.enumerate()
.filter(|(i, _)| *i != self.active)
.flat_map(|(_, t)| t.pane.terminals())
.find(runs_agent)
}
fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
return;
};
target.read(cx).send_agent_prompt(prompt);
if let Some(i) = self
.tabs
.iter()
.position(|t| t.pane.terminals().contains(&target))
{
self.activate(i, window, cx);
}
}
fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let source = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx));
let (selection, cwd) = match &source {
Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()),
None => (None, None),
};
let Some(selection) = selection else {
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNothingSelected));
return;
};
let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
if let Some(prompt) =
crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref())
{
self.deliver_agent_prompt(&prompt, window, cx);
}
}
fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let pane = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx));
let target = pane.and_then(|view| {
let view = view.read(cx);
Some((view.host(cx)?, view.host_cwd()?))
});
let Some((host, cwd)) = target else {
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppPaneNoKnownDirectory));
return;
};
crate::ui::host_ops::HostOps::run_in(
host,
window,
cx,
move |h| {
let run = |args: &[&str]| {
h.git(&cwd, args)
.ok()
.filter(|o| o.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
};
let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
(diff, cwd.to_string_lossy().into_owned())
},
move |this, (diff, cwd_s), window, cx| {
match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
Some(prompt) => this.deliver_agent_prompt(&prompt, window, cx),
None => crate::terminal::notify_desktop(
Some("tty7"),
&t_fmt(L10nKey::AppNoUncommittedChanges, &[("cwd", &cwd_s)]),
),
}
},
);
}
pub(crate) fn reset_settings_value(
&mut self,
title: L10nKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let defaults = Config::default();
match title {
L10nKey::SettingsDimInactivePanes => {
self.set_dim_inactive_panes(defaults.dim_inactive_panes, cx)
}
L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx),
L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx),
L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx),
L10nKey::SettingsNewTabPosition => {
self.set_new_tab_position(defaults.new_tab_position, cx)
}
L10nKey::SettingsTabBarPosition => {
self.set_tab_bar_position(defaults.tab_bar_position, cx)
}
L10nKey::SettingsSidebarGrouping => {
self.set_sidebar_grouping(defaults.sidebar_grouping, cx)
}
L10nKey::SettingsDiffPreviewFromCounts => {
self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx)
}
L10nKey::SettingsNotifyOnCommandFinish => {
self.set_notify_mode(defaults.notify_on_command_finish, cx)
}
L10nKey::SettingsNotifyThreshold => {
self.set_notify_threshold(defaults.notify_threshold_secs, cx)
}
L10nKey::SettingsTerminalBell => self.set_bell_mode(defaults.bell, cx),
L10nKey::SettingsRestoreLastLayout => {
self.set_restore_session(defaults.restore_session, cx)
}
L10nKey::SettingsPerPaneHistory => {
self.set_per_pane_history(defaults.per_pane_history, cx)
}
L10nKey::SettingsShowTrayIcon => self.set_show_tray_icon(defaults.show_tray_icon, cx),
L10nKey::SettingsOptionAsMeta => {
self.set_macos_option_as_alt(defaults.macos_option_as_alt, cx)
}
L10nKey::SettingsHideMouseWhileTyping => {
self.set_mouse_hide_while_typing(defaults.mouse_hide_while_typing, cx)
}
L10nKey::SettingsFocusFollowsMouse => {
self.set_focus_follows_mouse(defaults.focus_follows_mouse, cx)
}
L10nKey::SettingsReportMouseToApps => {
self.set_mouse_reporting(defaults.mouse_reporting, cx)
}
L10nKey::SettingsScrollSpeed => {
self.set_mouse_scroll_multiplier(defaults.mouse_scroll_multiplier, cx)
}
L10nKey::SettingsSmoothScroll => self.set_smooth_scroll(defaults.smooth_scroll, cx),
L10nKey::SettingsMouseZoom => {
self.set_mouse_zoom_modifier(defaults.mouse_zoom_modifier, cx)
}
L10nKey::SettingsTrimTrailingSpaces => {
self.set_clipboard_trim(defaults.clipboard_trim_trailing_spaces, cx)
}
L10nKey::SettingsCopyOnSelect => self.set_copy_on_select(defaults.copy_on_select, cx),
L10nKey::SettingsSmartSelection => self.set_smart_select(defaults.smart_select, cx),
L10nKey::SettingsPromptEditor => self.set_prompt_editor(defaults.prompt_editor, cx),
L10nKey::SettingsTabCompletion => self.set_tab_completion(defaults.tab_completion, cx),
L10nKey::SettingsHistorySearch => self.set_history_search(defaults.history_search, cx),
L10nKey::SettingsStartupWindow => self.set_startup_mode(defaults.startup_mode, cx),
L10nKey::SettingsRememberWindowSize => {
self.set_remember_window_size(defaults.remember_window_size, cx)
}
L10nKey::SettingsCheckUpdatesOnLaunch => {
self.set_check_for_updates(defaults.check_for_updates, cx)
}
L10nKey::SettingsAutoDownload => {
self.set_auto_download_updates(defaults.auto_download_updates, cx)
}
L10nKey::SettingsUpdateChannel => self.set_update_channel(defaults.update_channel, cx),
L10nKey::DetectUrls => self.set_link_url(defaults.link_url, cx),
L10nKey::ForwardSshLoopbackLinks => {
self.set_ssh_loopback_forward(defaults.ssh_loopback_forward, cx)
}
L10nKey::SettingsVerifyHostKeys => {
self.set_verify_host_keys(defaults.verify_host_keys, cx)
}
L10nKey::WarnBeforeClosing => {
self.set_ssh_warn_on_close(defaults.ssh_warn_on_close, cx)
}
L10nKey::SettingsLanguage => self.set_gui_language(
Self::normalize_gui_language(&defaults.gui_language),
window,
cx,
),
L10nKey::SettingsFontSize => self.reset_font_size(cx),
L10nKey::SettingsUiFontSize => self.reset_ui_font_size(cx),
L10nKey::SettingsLineHeight => self.reset_line_height(cx),
L10nKey::SettingsFontFamily => {
self.commit_font_family(defaults.font_family.clone(), cx)
}
L10nKey::SettingsBoldFont => self.commit_font_family_emphasis(
true,
crate::ui::settings::font_default_label().to_string(),
cx,
),
L10nKey::SettingsItalicFont => self.commit_font_family_emphasis(
false,
crate::ui::settings::font_default_label().to_string(),
cx,
),
L10nKey::SettingsUiFontFamily => self.commit_ui_font_family(
crate::ui::settings::ui_font_default_label().to_string(),
window,
cx,
),
L10nKey::SettingsSyncWithSystem => {
self.set_theme_follow_system(defaults.theme_follow_system, window, cx)
}
L10nKey::SettingsLegiblePalette => {
self.set_theme_legible_palette(defaults.theme_legible_palette, window, cx)
}
L10nKey::SettingsOpacity => {
self.update_config(cx, |cfg| cfg.window_opacity = None);
apply_theme(Some(window), cx);
}
L10nKey::SettingsBlur => {
self.update_config(cx, |cfg| cfg.window_blur = None);
apply_theme(Some(window), cx);
}
#[cfg(target_os = "windows")]
L10nKey::SettingsBackdrop => {
self.set_window_backdrop(defaults.window_backdrop, window, cx)
}
L10nKey::SettingsFontLigatures => self.set_font_ligatures(
defaults
.font_features
.as_ref()
.is_some_and(|f| f.is_calt_enabled() == Some(true)),
cx,
),
L10nKey::OpenFilesWith => {
self.update_config(cx, |cfg| cfg.link_file_open = defaults.link_file_open);
}
L10nKey::SettingsProgram => {
self.update_config(cx, |cfg| cfg.shell = defaults.shell.clone())
}
L10nKey::SettingsArguments => self.update_config(cx, |cfg| {
if let Some(shell) = &mut cfg.shell {
shell.args.clear();
}
}),
L10nKey::SettingsStartIn => self.update_config(cx, |cfg| {
cfg.working_directory.strategy = defaults.working_directory.strategy
}),
L10nKey::SettingsCustomPath => self.update_config(cx, |cfg| {
cfg.working_directory.path = defaults.working_directory.path.clone()
}),
L10nKey::SettingsAppHttpProxy => {
self.update_config(cx, |cfg| cfg.http_proxy = defaults.http_proxy.clone())
}
L10nKey::SettingsOpenFilesCommand => self.update_config(cx, |cfg| {
cfg.link_file_command = defaults.link_file_command.clone()
}),
_ => return,
}
self.refresh_settings_controls(title, window, cx);
}
fn refresh_settings_controls(
&mut self,
title: L10nKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut subs = Vec::new();
match title {
L10nKey::SettingsFontFamily
| L10nKey::SettingsBoldFont
| L10nKey::SettingsItalicFont
| L10nKey::SettingsUiFontFamily => {
let (font, bold, italic, ui) = self.build_font_selects(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.font_select = font;
s.font_bold_select = bold;
s.font_italic_select = italic;
s.ui_font_select = ui;
}
}
L10nKey::SettingsLanguage => {
let value = self.build_language_select(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.language_select = value;
}
}
L10nKey::SettingsProgram | L10nKey::SettingsArguments => {
let (program, args, _) = self.build_shell_inputs(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.shell_program_input = program;
s.shell_args_input = args;
}
}
L10nKey::SettingsCustomPath => {
let value = cx.global::<Config>().working_directory.path.clone();
if let Some(s) = self.active_settings() {
s.wd_path_input
.clone()
.update(cx, |s, cx| s.set_value(value, window, cx));
}
}
L10nKey::SettingsOpenFilesCommand => {
let value = self.build_link_file_command_input(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.link_file_command_input = value;
}
}
L10nKey::SettingsAppHttpProxy => {
let value = self.build_http_proxy_input(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.http_proxy_input = value;
}
}
L10nKey::SettingsScrollSpeed => {
let value = self.build_scroll_slider(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.scroll_slider = value;
}
}
L10nKey::SettingsOpacity | L10nKey::SettingsBlur | L10nKey::SettingsBackdrop => {
let value = self.build_window_opacity_slider(&mut subs, window, cx);
if let Some(s) = self.active_settings_mut() {
s.window_opacity_slider = value;
}
}
_ => {}
}
if let Some(s) = self.active_settings_mut() {
s._subs.extend(subs);
}
cx.notify();
}
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.is_some() {
self.close_settings_checked(window, cx);
return;
}
self.remember_active_pane(window, cx);
let focus_handle = cx.focus_handle();
let mut subs = Vec::new();
let (font_select, font_bold_select, font_italic_select, ui_font_select) =
self.build_font_selects(&mut subs, window, cx);
let language_select = self.build_language_select(&mut subs, window, cx);
#[cfg(target_os = "windows")]
let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
let http_proxy_input = self.build_http_proxy_input(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
let theme_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchThemes))
});
subs.push(
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
let settings_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchSettings))
});
subs.push(
cx.subscribe_in(&settings_search, window, |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
this.autoselect_settings_search(cx);
if let Some(s) = this.active_settings_mut() {
s.reveal_first_hit.set(true);
}
cx.notify();
}
}),
);
let shortcut_search = cx
.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::SettingsNavKeybindings)));
subs.push(
cx.subscribe_in(&shortcut_search, window, |_, _, ev, _, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
let ssh_filter = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::FilterHosts))
});
subs.push(
cx.subscribe_in(&ssh_filter, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
let ssh_quick_connect = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderSshQuickConnect))
});
subs.push(
cx.subscribe_in(&ssh_quick_connect, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
let content_scroll = gpui::ScrollHandle::new();
content_scroll.set_offset(self.last_settings_location.1);
let search_anchor = gpui::ScrollAnchor::for_handle(content_scroll.clone());
self.settings = Some(SettingsState {
focus_handle: focus_handle.clone(),
section: self.last_settings_location.0,
search: settings_search,
shortcut_search,
modified_only: false,
search_active: false,
search_return_offset: self.last_settings_location.1,
search_selection: 0,
search_rows: std::cell::RefCell::new(None),
focused_setting: None,
content_scroll,
ssh_master_scroll: gpui::ScrollHandle::new(),
ssh_detail_scroll: gpui::ScrollHandle::new(),
theme_list_scroll: gpui::ScrollHandle::new(),
search_anchor,
reveal_first_hit: Cell::new(false),
font_select,
font_bold_select,
font_italic_select,
ui_font_select,
language_select,
#[cfg(target_os = "windows")]
window_backdrop_select,
shell_program_input,
shell_args_input,
wd_path_input,
link_file_command_input,
http_proxy_input,
scroll_slider,
window_opacity_slider,
theme_editor: None,
theme_draft: None,
theme_draft_error: None,
save_error: None,
saved_config: cx.global::<Config>().clone(),
theme_panel_open: false,
theme_panel_slot: crate::ui::settings::ThemeSlot::Manual,
theme_search,
recording: None,
rebinding_note: None,
ssh_form: None,
ssh_detail: crate::ui::settings::SshDetail::None,
ssh_filter,
ssh_collapsed_groups: std::collections::HashSet::new(),
ssh_quick_connect,
agent_hooks_host: crate::ui::host_ops::HostId::LOCAL,
agent_hooks_states: crate::ui::settings::AgentHooksView::Loading,
agent_hooks_seq: 0,
agent_hooks_note: None,
_subs: subs,
});
let search_focus = self
.settings
.as_ref()
.map(|s| s.search.read(cx).focus_handle(cx));
match search_focus {
Some(handle) => window.focus(&handle, cx),
None => window.focus(&focus_handle, cx),
}
self.rebuild_theme_editor(window, cx);
self.ensure_agent_hooks_loaded(cx);
cx.notify();
}
fn build_font_selects(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
) {
let cfg = cx.global::<Config>();
let family = cfg.font_family.clone();
let font_bold = cfg.font_family_bold.clone();
let font_italic = cfg.font_family_italic.clone();
let ui_font_family = cfg.ui_font_family.clone();
let mut font_names = cx.text_system().all_font_names();
if !font_names.contains(&family) {
font_names.push(family.clone());
font_names.sort_unstable();
}
if let Some(ui_font) = &ui_font_family
&& !font_names.contains(ui_font)
{
font_names.push(ui_font.clone());
font_names.sort_unstable();
}
let selected_font_index = font_names
.iter()
.position(|n| *n == family)
.map(|row| IndexPath::default().row(row));
let font_select = cx.new(|cx| {
SelectState::new(
SearchableVec::new(font_names.clone()),
selected_font_index,
window,
cx,
)
.searchable(true)
});
let build_alt_font_select = |value: &Option<String>,
default_label: &str,
names: &[String],
window: &mut Window,
cx: &mut Context<Self>| {
let mut rows = Vec::with_capacity(names.len() + 1);
rows.push(default_label.to_string());
rows.extend(names.iter().cloned());
let selected = value
.as_ref()
.and_then(|v| rows.iter().position(|n| n == v))
.unwrap_or(0);
cx.new(|cx| {
SelectState::new(
SearchableVec::new(rows),
Some(IndexPath::default().row(selected)),
window,
cx,
)
.searchable(true)
})
};
let alt_default = crate::ui::settings::font_default_label();
let font_bold_select =
build_alt_font_select(&font_bold, alt_default, &font_names, window, cx);
let font_italic_select =
build_alt_font_select(&font_italic, alt_default, &font_names, window, cx);
let ui_font_select = build_alt_font_select(
&ui_font_family,
crate::ui::settings::ui_font_default_label(),
&font_names,
window,
cx,
);
subs.push(cx.subscribe_in(
&font_select,
window,
|this, _select, ev: &SelectEvent<SearchableVec<String>>, _window, cx| {
if let SelectEvent::Confirm(Some(family)) = ev {
this.commit_font_family(family.clone(), cx);
}
},
));
subs.push(cx.subscribe_in(
&font_bold_select,
window,
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
if let SelectEvent::Confirm(Some(name)) = ev {
this.commit_font_family_emphasis(true, name.clone(), cx);
}
},
));
subs.push(cx.subscribe_in(
&font_italic_select,
window,
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
if let SelectEvent::Confirm(Some(name)) = ev {
this.commit_font_family_emphasis(false, name.clone(), cx);
}
},
));
subs.push(cx.subscribe_in(
&ui_font_select,
window,
|this, _s, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
if let SelectEvent::Confirm(Some(name)) = ev {
this.commit_ui_font_family(name.clone(), window, cx);
}
},
));
(
font_select,
font_bold_select,
font_italic_select,
ui_font_select,
)
}
fn build_language_select(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SelectState<SearchableVec<String>>> {
let labels = || {
crate::ui::i18n::SUPPORTED_LANGUAGES
.iter()
.map(|lang| t(lang.label_key).to_string())
.collect::<Vec<_>>()
};
let cfg = cx.global::<Config>();
let current = Self::normalize_gui_language(&cfg.gui_language);
let rows = labels();
let selected = crate::ui::i18n::SUPPORTED_LANGUAGES
.iter()
.position(|lang| lang.code == current)
.unwrap_or(0);
let language_select = cx.new(|cx| {
SelectState::new(
SearchableVec::new(rows),
Some(IndexPath::default().row(selected)),
window,
cx,
)
});
subs.push(cx.subscribe_in(
&language_select,
window,
move |this, _select, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
if let SelectEvent::Confirm(Some(label)) = ev {
let rows = labels();
if let Some(idx) = rows.iter().position(|r| r == label) {
if let Some(lang) = crate::ui::i18n::SUPPORTED_LANGUAGES.get(idx) {
this.set_gui_language(lang.code, window, cx);
}
}
}
},
));
language_select
}
fn normalize_gui_language(code: &str) -> &'static str {
crate::ui::i18n::find_language(code)
.map(|lang| lang.code)
.unwrap_or_else(crate::ui::i18n::default_language_code)
}
/// The backdrop dropdown only lists the presets this Windows build
/// supports, in the order of `theme::supported_backdrops`; the select
/// resolves the picked label back through that same list.
#[cfg(target_os = "windows")]
fn build_window_backdrop_select(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SelectState<SearchableVec<String>>> {
let rows = window_backdrop_labels(cx.global::<Config>().window_backdrop);
let selected = window_backdrop_index(cx.global::<Config>().window_backdrop);
let select = cx.new(|cx| {
SelectState::new(
SearchableVec::new(rows),
Some(IndexPath::default().row(selected)),
window,
cx,
)
});
subs.push(cx.subscribe_in(
&select,
window,
move |this, _select, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
if let SelectEvent::Confirm(Some(label)) = ev {
let current = cx.global::<Config>().window_backdrop;
let rows = window_backdrop_labels(current);
if let Some(idx) = rows.iter().position(|row| row == label) {
this.set_window_backdrop(
window_backdrop_from_index(idx, current),
window,
cx,
);
}
}
},
));
select
}
pub(crate) fn set_gui_language(
&mut self,
code: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let code = Self::normalize_gui_language(code);
{
let cfg = cx.global_mut::<Config>();
cfg.gui_language = code.to_string();
}
set_locale(code);
self.persist_settings_config(cx);
set_menus(cx);
// Explorer reads its menu wording from the registry, so it is the one
// surface a language change does not reach on its own. No-op unless
// the user installed the context menu, and off Windows entirely.
crate::core::explorer_context_menu::refresh_labels();
self.refresh_locale_state(window, cx);
crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace));
}
pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.sidebar_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchTabs), window, cx)
});
self.file_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
});
// The remote Files panel is built once with the app, so its placeholder
// is the one input that would otherwise keep the old language.
self.sftp_panel.filter_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
});
if let Some(s) = self.active_settings() {
let rows = crate::ui::i18n::SUPPORTED_LANGUAGES
.iter()
.map(|lang| t(lang.label_key).to_string())
.collect::<Vec<_>>();
s.language_select.update(cx, |state, cx| {
state.set_items(SearchableVec::new(rows), window, cx);
let code = Self::normalize_gui_language(&cx.global::<Config>().gui_language);
let selected = crate::ui::i18n::SUPPORTED_LANGUAGES
.iter()
.position(|lang| lang.code == code)
.unwrap_or(0);
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
});
#[cfg(target_os = "windows")]
s.window_backdrop_select.update(cx, |state, cx| {
let current = cx.global::<Config>().window_backdrop;
let rows = window_backdrop_labels(current);
state.set_items(SearchableVec::new(rows), window, cx);
// `set_items` does not preserve the selection; restore the
// index of the stored value so a locale refresh (which
// re-translates the labels) cannot leave the dropdown
// showing no — or the wrong — selection.
state.set_selected_index(
Some(IndexPath::default().row(window_backdrop_index(current))),
window,
cx,
);
});
s.search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchSettings), window, cx)
});
s.theme_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchThemes), window, cx)
});
s.ssh_filter.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::FilterHosts), window, cx)
});
s.ssh_quick_connect.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderSshQuickConnect), window, cx)
});
s.shell_args_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderNone), window, cx)
});
if !cfg!(windows) {
s.shell_program_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderLoginShell), window, cx)
});
}
s.link_file_command_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp), window, cx)
});
}
cx.notify();
}
fn build_shell_inputs(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (Entity<InputState>, Entity<InputState>, Entity<InputState>) {
let cfg = cx.global::<Config>();
let (shell_program, shell_args) = match &cfg.shell {
// Quote each argument back into field text rather than joining on
// spaces: `join(" ")` cannot spell an argument that contains one,
// so a perfectly legal `"args": ["-c", "echo hi"]` in config.json
// refilled as three words and re-committed as three argv the moment
// the field lost focus — the user never typed a thing (#551).
// `join_shell_args` quotes only what needs it, and `commit_shell`'s
// matching `split_shell_args` parses it back losslessly.
Some(s) => (s.program.clone(), join_shell_args(&s.args)),
None => (String::new(), String::new()),
};
let wd_path = cfg.working_directory.path.clone();
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
t(L10nKey::AppPlaceholderLoginShell)
};
let shell_program_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(platform_default)
.default_value(shell_program)
});
let shell_args_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t(L10nKey::AppPlaceholderNone))
.default_value(shell_args)
});
let wd_path_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("/path/to/directory")
.default_value(wd_path)
});
let commit_shell = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_shell(cx);
}
};
let commit_wd = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_working_directory_path(cx);
}
};
subs.push(
cx.subscribe_in(&shell_program_input, window, move |this, _i, ev, _w, cx| {
commit_shell(this, ev, cx)
}),
);
subs.push(
cx.subscribe_in(&shell_args_input, window, move |this, _i, ev, _w, cx| {
commit_shell(this, ev, cx)
}),
);
subs.push(
cx.subscribe_in(&wd_path_input, window, move |this, _i, ev, _w, cx| {
commit_wd(this, ev, cx)
}),
);
(shell_program_input, shell_args_input, wd_path_input)
}
fn build_link_file_command_input(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let value = cx
.global::<Config>()
.link_file_command
.clone()
.unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp))
.default_value(value)
});
subs.push(
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_link_file_command(cx);
}
}),
);
input
}
fn build_http_proxy_input(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let value = cx.global::<Config>().http_proxy.clone().unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("http://127.0.0.1:7890")
.default_value(value)
});
subs.push(
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_http_proxy(cx);
}
}),
);
input
}
fn commit_http_proxy(&mut self, cx: &mut Context<Self>) {
let Some(value) = self
.active_settings()
.map(|s| s.http_proxy_input.read(cx).value().trim().to_string())
else {
return;
};
// Keep an unusable value out of `config.json`. The row renders a hint
// under the input, so the typo does not silently vanish either.
if !value.is_empty() && !tty7_core::daemon::install::proxy::is_valid_manual(&value) {
cx.notify();
return;
}
let value = (!value.is_empty()).then_some(value);
let cfg = cx.global_mut::<Config>();
if cfg.http_proxy == value {
return;
}
cfg.http_proxy = value;
self.persist_settings_config(cx);
cx.notify();
}
fn commit_link_file_command(&mut self, cx: &mut Context<Self>) {
let Some(command) = self.active_settings().map(|s| {
s.link_file_command_input
.read(cx)
.value()
.trim()
.to_string()
}) else {
return;
};
let command = if command.is_empty() {
None
} else {
Some(command)
};
let cfg = cx.global_mut::<Config>();
if cfg.link_file_command == command {
return;
}
cfg.link_file_command = command;
self.persist_settings_config(cx);
cx.notify();
}
fn build_window_opacity_slider(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SliderState> {
let eff = Self::effective_window_opacity(cx);
let slider = cx.new(|_| {
SliderState::new()
.min(0.2)
.max(1.0)
.step(0.01)
.default_value(eff)
});
subs.push(cx.subscribe_in(
&slider,
window,
|this, _s, ev: &SliderEvent, window, cx| match ev {
SliderEvent::Change(v) => {
cx.global_mut::<Config>().window_opacity = Some(v.start().clamp(0.2, 1.0));
apply_theme(Some(window), cx);
cx.notify();
}
SliderEvent::Release(_) => this.persist_settings_config(cx),
},
));
slider
}
fn build_scroll_slider(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SliderState> {
let scroll_mult = cx.global::<Config>().mouse_scroll_multiplier;
let scroll_slider = cx.new(|_| {
SliderState::new()
.min(0.5)
.max(5.0)
.step(0.25)
.default_value(scroll_mult)
});
subs.push(cx.subscribe_in(
&scroll_slider,
window,
|this, _s, ev: &SliderEvent, _w, cx| match ev {
SliderEvent::Change(v) => {
cx.global_mut::<Config>().mouse_scroll_multiplier = v.start().clamp(0.1, 10.0);
cx.notify();
}
SliderEvent::Release(_) => this.persist_settings_config(cx),
},
));
scroll_slider
}
pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(s) = self.settings.take() {
self.last_settings_location = (
s.section,
if s.search_active {
s.search_return_offset
} else {
s.content_scroll.offset()
},
);
self.focus_active(window, cx);
cx.notify();
}
}
pub(crate) fn open_settings_section(
&mut self,
section: SettingsSection,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.settings.is_none() {
self.toggle_settings(window, cx);
}
self.navigate_settings(section, None, window, cx);
}
/// Resolve a pending form before performing both parts of an external
/// navigation. Opening the page and loading its form must be one action.
pub(crate) fn open_ssh_profile_form(
&mut self,
profile: crate::core::ssh_profile::SshProfile,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
this.open_settings_section(SettingsSection::Ssh, window, cx);
this.ssh_form_load(&profile, window, cx);
});
}
pub(crate) fn open_ssh_profile_in_settings(
&mut self,
id: uuid::Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
// Read after resolving edits, so saving and reopening the same
// profile cannot load a snapshot from before that save.
if let Some(profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.cloned()
{
this.open_ssh_profile_form(profile, window, cx);
} else {
this.open_settings_section(SettingsSection::Ssh, window, cx);
}
});
}
pub(crate) fn open_ssh_profile_new_from_target(
&mut self,
target: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut profile = crate::core::ssh_profile::SshProfile::new(String::new());
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
profile.port = qc.port_or_default();
profile.host = qc.host;
if let Some(user) = qc.user {
profile.user = user;
}
if profile.name.is_empty() {
profile.name = profile.host.clone();
}
}
self.open_ssh_profile_form(profile, window, cx);
}
fn commit_font_family(&mut self, family: String, cx: &mut Context<Self>) {
self.font_family = family.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let family = family.clone();
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_family = family;
self.persist_settings_config(cx);
cx.notify();
}
fn commit_font_family_emphasis(&mut self, bold: bool, name: String, cx: &mut Context<Self>) {
let family = (name != crate::ui::settings::font_default_label()).then_some(name);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let family = family.clone();
leaf.update(cx, |v, cx| {
if bold {
v.set_font_family_bold(family, cx);
} else {
v.set_font_family_italic(family, cx);
}
});
}
}
if bold {
self.font_family_bold = family.clone();
} else {
self.font_family_italic = family.clone();
}
let cfg = cx.global_mut::<Config>();
if bold {
cfg.font_family_bold = family;
} else {
cfg.font_family_italic = family;
}
self.persist_settings_config(cx);
cx.notify();
}
fn commit_ui_font_family(&mut self, name: String, window: &mut Window, cx: &mut Context<Self>) {
let family = (name != crate::ui::settings::ui_font_default_label()).then_some(name);
let cfg = cx.global_mut::<Config>();
if cfg.ui_font_family == family {
return;
}
cfg.ui_font_family = family;
self.persist_settings_config(cx);
apply_theme(Some(window), cx);
cx.refresh_windows();
cx.notify();
}
fn reload_from_config(&mut self, window: &mut Window, cx: &mut Context<Self>) {
apply_theme(Some(window), cx);
self.sync_window_opacity_slider(window, cx);
// Another window — or a hand edit / config sync picked up by the
// `Config` watcher — can change the backdrop while this window's
// settings panel is open. The window itself already switched
// material above, so the dropdown has to follow or it contradicts
// what it describes.
#[cfg(target_os = "windows")]
self.sync_window_backdrop_select(window, cx);
let config = cx.global::<Config>().clone();
if config.cursor_style != self.terminal_cursor_style
|| config.scrollback_limit != self.terminal_scrollback_limit
{
self.terminal_cursor_style = config.cursor_style;
self.terminal_scrollback_limit = config.scrollback_limit;
self.apply_terminal_config_to_panes(&config, cx);
}
let (font_size, line_height, font_family, font_fallbacks, font_features) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_fallbacks.clone(),
cfg.font_features
.as_ref()
.map(crate::core::config::gpui_font_features),
)
};
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
self.right_panel_width
.set(cx.global::<Config>().right_panel_width);
self.document_ratio
.set(cx.global::<Config>().document_ratio);
if font_size != self.font_size {
self.font_size = font_size;
let px_size = px(font_size);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.font_size = px_size;
cx.notify();
});
}
}
}
if line_height != self.line_height {
self.line_height = line_height;
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.line_height_mul = line_height;
cx.notify();
});
}
}
}
if font_family != self.font_family {
self.font_family = font_family.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let family = font_family.clone();
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
}
}
}
// A `font_fallbacks` edit on its own reached no live pane at all: the
// chain is built once per view and from then on only cloned around.
// `set_font_family` rereads it too, so an edit that moves both ends up
// building the same chain twice rather than disagreeing about it.
if font_fallbacks != self.font_fallbacks {
self.font_fallbacks = font_fallbacks;
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
v.reread_fallback_chain(cx);
cx.notify();
});
}
}
}
if font_features != self.font_features {
self.font_features = font_features.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let features = font_features.clone();
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
}
}
}
let (bold, italic) = {
let cfg = cx.global::<Config>();
(cfg.font_family_bold.clone(), cfg.font_family_italic.clone())
};
if bold != self.font_family_bold {
self.font_family_bold = bold.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let bold = bold.clone();
leaf.update(cx, |v, cx| v.set_font_family_bold(bold, cx));
}
}
}
if italic != self.font_family_italic {
self.font_family_italic = italic.clone();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let italic = italic.clone();
leaf.update(cx, |v, cx| v.set_font_family_italic(italic, cx));
}
}
}
let report_mouse = cx.global::<Config>().mouse_reporting;
let prompt_editor = cx.global::<Config>().prompt_editor;
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |v, cx| {
if v.report_mouse != report_mouse {
v.report_mouse = report_mouse;
cx.notify();
}
// A hand edit of `config.json` — or another window's
// settings page — has to move a live pane between the
// local editor and ZLE too, not just the window that
// flipped the switch.
v.set_prompt_editor(prompt_editor, cx);
});
}
}
cx.notify();
}
/// Saves the shell after the detected-shell menu wrote into the field.
///
/// The field itself only commits on Enter or blur, and picking from a menu
/// is neither — without this the choice would sit in the box unsaved until
/// the user happened to click into it and out again.
pub(crate) fn commit_shell_from_picker(&mut self, cx: &mut Context<Self>) {
self.commit_shell(cx);
cx.notify();
}
fn commit_shell(&mut self, cx: &mut Context<Self>) {
let Some(settings) = self.active_settings() else {
return;
};
let program = settings
.shell_program_input
.read(cx)
.value()
.trim()
.to_string();
// Split the way a command line splits, not on raw whitespace: the text
// goes on to become argv verbatim, and `split_whitespace` kept the
// quote characters while tearing the quoted string apart — `-c "echo
// hi"` reached the shell as `["-c", "\"echo", "hi\""]` (#551).
let raw_args = settings.shell_args_input.read(cx).value();
let args: Vec<String> = match split_shell_args(&raw_args) {
Ok(args) => args,
// A quote that never closes names no argv at all, so this text
// cannot go into `config.json` — the proxy row's pattern: leave the
// stored arguments alone and let the row explain why under the
// input after this notify. The rest of the row still commits: the
// Program the user just typed, or picked from the menu, is not
// theirs to lose over the field below it.
Err(_) => {
cx.notify();
let cfg = cx.global::<Config>();
cfg.shell
.as_ref()
.map(|s| s.args.clone())
.unwrap_or_default()
}
};
let shell = if program.is_empty() {
None
} else {
Some(ShellConfig { program, args })
};
{
let cfg = cx.global_mut::<Config>();
if cfg.shell == shell {
return;
}
cfg.shell = shell;
self.persist_settings_config(cx);
}
// Shell discovery runs off the UI thread and now includes the saved
// configured shell, so refresh the menu without blocking Settings.
self.refresh_shells(cx);
}
pub(crate) fn set_working_directory_strategy(
&mut self,
strategy: crate::core::config::WdStrategy,
cx: &mut Context<Self>,
) {
let cfg = cx.global_mut::<Config>();
if cfg.working_directory.strategy == strategy {
return;
}
cfg.working_directory.strategy = strategy;
self.persist_settings_config(cx);
cx.notify();
}
fn commit_working_directory_path(&mut self, cx: &mut Context<Self>) {
let Some(path) = self
.active_settings()
.map(|s| s.wd_path_input.read(cx).value().trim().to_string())
else {
return;
};
// A typo here is not a directory, and the daemon then silently falls
// back to its own cwd for every new pane — "new shells don't start in
// my project" reads as a tty7 bug rather than a typo (#601). Refuse
// to save, the proxy row's pattern (#551): the field keeps the text,
// the settings row explains in red, and the last good value stays in
// config.json.
if !wd_path_saveable(&path) {
cx.notify();
return;
}
let cfg = cx.global_mut::<Config>();
if cfg.working_directory.path == path {
return;
}
cfg.working_directory.path = path;
self.persist_settings_config(cx);
cx.notify();
}
pub(crate) fn active_settings(&self) -> Option<&SettingsState> {
self.settings.as_ref()
}
pub(crate) fn active_settings_mut(&mut self) -> Option<&mut SettingsState> {
self.settings.as_mut()
}
pub(crate) fn tab_ssh_dot(&self, tab: &Tab, cx: &App) -> Option<u32> {
use crate::daemon::protocol::SshPhase;
let leaf = tab.pane.first_leaf()?;
let v = leaf.terminal()?.read(cx);
if let Some(phase) = v.ssh_phase() {
let rgb = if v.ssh_disconnected() {
0xEF4444
} else {
match phase {
SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B,
SshPhase::Connected => 0x22C55E,
SshPhase::Failed { .. } => 0xEF4444,
}
};
Some(rgb)
} else if v
.remote_context()
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
{
Some(0x9CA3AF)
} else {
None
}
}
fn leaf_is_warn_ssh(&self, leaf: &Entity<TerminalView>, cx: &App) -> bool {
use crate::daemon::protocol::SshPhase;
let v = leaf.read(cx);
let connected = matches!(v.ssh_phase(), Some(SshPhase::Connected)) && !v.terminal.exited;
if !connected {
return false;
}
let cfg = cx.global::<Config>();
let per_profile = v
.ssh_spec()
.and_then(|s| s.profile_id.clone())
.and_then(|id| uuid::Uuid::parse_str(&id).ok())
.and_then(|id| cfg.ssh_profiles.iter().find(|p| p.id == id))
.and_then(|p| p.warn_on_close);
per_profile.unwrap_or(cfg.ssh_warn_on_close)
}
/// The app asks every other question of this class through the platform's
/// own dialog. This one used to be a bespoke in-app card with no scrim, no
/// Escape and no click-outside — the two buttons were the only way out.
fn ask_before_closing(
&mut self,
target: CloseTarget,
reason: CloseReason,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.close_prompt_open {
return;
}
self.close_prompt_open = true;
// ⌘W closes a pane, but when it is the only one in its tab the tab goes
// with it — the question has to name what actually disappears.
let ends_the_tab = matches!(target, CloseTarget::Tab(_))
|| self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
let (title, body) = close_prompt(ends_the_tab, &reason);
let answer = window.prompt(
PromptLevel::Warning,
&title,
Some(&body),
&crate::ui::confirm_answers(
t(crate::ui::i18n::L10nKey::Close),
t(crate::ui::i18n::L10nKey::Keep),
),
cx,
);
cx.spawn_in(window, async move |this, cx| {
let close = matches!(answer.await, Ok(0));
let _ = this.update_in(cx, |this, window, cx| {
this.close_prompt_open = false;
// Cancelled, or the window went away with the question open:
// either way the work carries on.
if !close {
cx.notify();
return;
}
match target {
// The tab may have moved, or gone, while the question was
// up; find it by id and let it be if it is already closed.
CloseTarget::Tab(id) => {
if let Some(i) = this.tabs.iter().position(|t| t.tree_id.get() == id) {
this.close_tab_inner(i, true, window, cx);
}
}
CloseTarget::Pane => this.close_pane_inner(true, window, cx),
}
});
})
.detach();
}
/// The first reason this pane should not simply vanish.
fn leaf_close_reason(&self, leaf: &Entity<TerminalView>, cx: &App) -> Option<CloseReason> {
if self.leaf_is_warn_ssh(leaf, cx) {
return Some(CloseReason::LiveSsh);
}
leaf.read(cx).busy().map(CloseReason::Busy)
}
/// Whether closing this tab would drop a connection the user asked to be
/// warned about. Narrower than [`Self::tab_close_reason`] on purpose — see
/// the bulk closes, which skip these and only these.
fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool {
self.tabs.get(index).is_some_and(|tab| {
tab.pane
.terminals()
.iter()
.any(|l| self.leaf_is_warn_ssh(l, cx))
})
}
fn tab_close_reason(&self, index: usize, cx: &App) -> Option<CloseReason> {
self.tabs
.get(index)?
.pane
.terminals()
.iter()
.find_map(|l| self.leaf_close_reason(l, cx))
}
fn focused_pane_close_reason(&self, window: &Window, cx: &App) -> Option<CloseReason> {
let leaf = self
.tabs
.get(self.active)?
.pane
.focused_or_first(window, cx)?;
self.leaf_close_reason(&leaf, cx)
}
pub(crate) fn active_ssh_pane(
&self,
window: &Window,
cx: &App,
) -> Option<(u64, RemoteContext)> {
let pane = self
.tabs
.get(self.active)?
.pane
.focused_or_first(window, cx)?;
let pane = pane.read(cx);
let remote = pane.remote_context()?;
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
}
pub(crate) fn active_connected_native_ssh_pane(
&self,
window: &Window,
cx: &App,
) -> Option<(u64, RemoteContext)> {
use crate::daemon::protocol::{RemoteKind, SshPhase};
let (pane_id, remote) = self.active_ssh_pane(window, cx)?;
if remote.kind != RemoteKind::NativeSsh {
return None;
}
let leaf = self
.tabs
.get(self.active)?
.pane
.focused_or_first(window, cx)?;
matches!(leaf.read(cx).ssh_phase(), Some(SshPhase::Connected)).then_some((pane_id, remote))
}
pub(crate) fn select_settings_section(
&mut self,
target: SettingsSection,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
let moved = s.section != target;
s.section = target;
s.recording = None;
// Arriving on a page with a query live means arriving to look for
// what the nav badge counted, so take the page to it.
if moved {
s.reveal_first_hit.set(true);
}
if target == SettingsSection::Agents {
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
}
}
self.ensure_agent_hooks_loaded(cx);
cx.notify();
}
fn ensure_agent_hooks_loaded(&mut self, cx: &mut Context<Self>) {
if self
.active_settings()
.is_some_and(|s| s.section == SettingsSection::Agents)
{
self.load_agent_hooks_states(cx);
}
}
pub(crate) fn agent_hooks_machines(
&self,
cx: &mut App,
) -> Vec<crate::ui::settings::AgentHooksMachine> {
use crate::ui::settings::AgentHooksMachine;
let mut out = vec![AgentHooksMachine {
host: crate::ui::host_ops::HostId::LOCAL,
label: t(L10nKey::AppAgentHooksThisComputer).to_string(),
}];
let configured = crate::ui::remote_connect::available_hosts(cx);
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
if id.is_local() {
continue;
}
let label = configured
.iter()
.find(|h| h.target.host_id() == id)
.map(|h| h.label.clone())
.unwrap_or_else(|| t(L10nKey::AppAgentHooksRemoteMachine).to_string());
out.push(AgentHooksMachine { host: id, label });
}
out
}
pub(crate) fn agent_hooks_offline_count(&self, cx: &mut App) -> usize {
let connected = crate::ui::host_registry::HostRegistry::ids(cx);
cx.global::<Config>()
.ssh_profiles
.iter()
.filter(|p| {
!connected
.contains(&crate::core::session::RemoteTarget::Profile { id: p.id }.host_id())
})
.count()
}
pub(crate) fn select_agent_hooks_host(
&mut self,
host: crate::ui::host_ops::HostId,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
if s.agent_hooks_host == host {
return;
}
s.agent_hooks_host = host;
s.agent_hooks_note = None;
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
}
self.load_agent_hooks_states(cx);
cx.notify();
}
fn load_agent_hooks_states(&mut self, cx: &mut Context<Self>) {
use crate::core::agent_hooks::{HookAgent, HookTarget};
use crate::ui::settings::{AgentHookRow, AgentHooksView};
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
return;
};
let seq = match self.settings.as_mut() {
Some(s) => {
s.agent_hooks_seq += 1;
s.agent_hooks_seq
}
None => return,
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_states = AgentHooksView::Unavailable(Self::agent_hooks_offline_msg());
}
cx.notify();
return;
};
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
let target = match &home {
Some(home) => HookTarget::remote(h, home.clone()),
None => HookTarget::local(h)?,
};
Some(
HookAgent::ALL
.into_iter()
.map(|agent| AgentHookRow {
agent,
state: crate::core::agent_hooks::hooks_state(&target, agent),
target: agent.target_display(&target),
})
.collect::<Vec<_>>(),
)
},
move |this, rows, cx| {
if let Some(s) = this.settings.as_mut()
&& s.agent_hooks_seq == seq
{
s.agent_hooks_states = match rows {
Some(rows) => AgentHooksView::Ready(rows),
None => AgentHooksView::Unavailable(
t(L10nKey::AppAgentHooksNoHomeDir).to_string(),
),
};
cx.notify();
}
},
);
}
fn agent_hooks_offline_msg() -> String {
t(L10nKey::AppAgentHooksOffline).to_string()
}
fn agent_hooks_link(
&self,
host_id: crate::ui::host_ops::HostId,
cx: &mut App,
) -> Option<(crate::ui::host_ops::SharedHost, Option<std::path::PathBuf>)> {
let host = crate::ui::host_registry::HostRegistry::get(cx, host_id)?;
if host_id.is_local() {
return Some((host, None));
}
if !host.is_connected() {
return None;
}
let home = crate::ui::remote_connect::HostLinks::home(cx, host_id)?;
Some((host, Some(home)))
}
pub(crate) fn settings_install_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
self.run_agent_hooks_action(agent, true, cx);
}
pub(crate) fn settings_uninstall_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
self.run_agent_hooks_action(agent, false, cx);
}
/// Words a hook install or removal for the note in Settings.
///
/// `agent_hooks` returns what it did rather than a sentence, because it
/// lives in `tty7-core` and cannot reach `src/ui/i18n` — it used to hand
/// back English prose, which a Chinese or Japanese UI then showed as-is.
fn agent_hooks_outcome_msg(outcome: &crate::core::agent_hooks::HookOutcome) -> String {
use crate::core::agent_hooks::HookOutcome as O;
match outcome {
O::Installed => t(L10nKey::AppAgentHooksInstalled).to_string(),
O::InstalledEnableCodexThere => {
t(L10nKey::AppAgentHooksInstalledEnableCodexThere).to_string()
}
O::InstalledCodexEnableFailed(e) => t_fmt(
L10nKey::AppAgentHooksInstalledCodexEnableFailed,
&[("error", e)],
),
O::Removed => t(L10nKey::AppAgentHooksRemoved).to_string(),
O::NothingInstalled => t(L10nKey::AppAgentHooksNothingInstalled).to_string(),
O::NoTty7Hooks => t(L10nKey::AppAgentHooksNoTty7Hooks).to_string(),
}
}
fn run_agent_hooks_action(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
install: bool,
cx: &mut Context<Self>,
) {
use crate::core::agent_hooks::HookTarget;
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
return;
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_note = Some((agent, Self::agent_hooks_offline_msg()));
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Unavailable(
Self::agent_hooks_offline_msg(),
);
}
cx.notify();
return;
};
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
let target = match &home {
Some(home) => HookTarget::remote(h, home.clone()),
None => HookTarget::local(h).ok_or_else(|| {
anyhow::anyhow!("{}", t(L10nKey::AppAgentHooksHomeDirUnresolved))
})?,
};
if install {
crate::core::agent_hooks::install_hooks(&target, agent)
} else {
crate::core::agent_hooks::uninstall_hooks(&target, agent)
}
},
move |this, result, cx| {
if let Some(s) = this.settings.as_mut() {
s.agent_hooks_note = Some((
agent,
match result {
Ok(outcome) => Self::agent_hooks_outcome_msg(&outcome),
// Every sibling error names its action; this one
// said only "Failed:", leaving the note that
// reports it silent about which half of the
// toggle had not happened.
Err(e) => t_fmt(
match install {
true => L10nKey::AppAgentHooksInstallFailed,
false => L10nKey::AppAgentHooksRemoveFailed,
},
&[("error", &e.to_string())],
),
},
));
}
this.load_agent_hooks_states(cx);
cx.notify();
},
);
}
pub(crate) fn autoselect_settings_search(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.settings.as_mut() {
let active = !s.search.read(cx).value().trim().is_empty() || s.modified_only;
if active && !s.search_active {
s.search_return_offset = s.content_scroll.offset();
}
if active {
s.content_scroll.set_offset(gpui::point(px(0.), px(0.)));
} else if s.search_active {
s.content_scroll.set_offset(s.search_return_offset);
}
s.search_active = active;
s.search_selection = 0;
if active {
s.focused_setting = None;
}
}
cx.notify();
}
pub(crate) fn start_recording_key(
&mut self,
action: String,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let this = cx.weak_entity();
let intercept = cx.intercept_keystrokes(move |ev, _window, cx| {
let keystroke = ev.keystroke.clone();
let _ = this.update(cx, |this, cx| this.on_record_key(&keystroke, cx));
cx.stop_propagation();
});
self.record_gen = self.record_gen.wrapping_add(1);
if let Some(s) = self.active_settings_mut() {
s.rebinding_note = None;
s.recording = Some(Recording {
action,
chords: Vec::new(),
_intercept: intercept,
});
}
cx.notify();
}
fn on_record_key(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
let Some((action, has_chords)) = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.map(|r| (r.action.clone(), !r.chords.is_empty()))
else {
return;
};
match keystroke.key.as_str() {
"escape" => {
self.stop_recording(cx);
return;
}
"backspace" | "delete" => {
if has_chords {
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.pop();
}
let still_has = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.is_some_and(|r| !r.chords.is_empty());
if still_has {
self.schedule_recording_commit(cx);
} else {
self.record_gen = self.record_gen.wrapping_add(1);
}
cx.notify();
} else {
self.stop_recording(cx);
self.unbind_keybinding(action, cx);
}
return;
}
_ => {}
}
let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else {
return;
};
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.push(spec);
}
self.schedule_recording_commit(cx);
cx.notify();
}
fn schedule_recording_commit(&mut self, cx: &mut Context<Self>) {
self.record_gen = self.record_gen.wrapping_add(1);
let generation = self.record_gen;
cx.spawn(async move |this, cx| {
smol::Timer::after(std::time::Duration::from_millis(RECORD_COMMIT_DELAY_MS)).await;
let _ = this.update(cx, |this, cx| {
if this.record_gen == generation {
this.commit_recording(cx);
}
});
})
.detach();
}
fn commit_recording(&mut self, cx: &mut Context<Self>) {
let Some((action, chords)) = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.filter(|r| !r.chords.is_empty())
.map(|r| (r.action.clone(), r.chords.clone()))
else {
return;
};
self.stop_recording(cx);
self.assign_keybinding(action, chords.join(" "), cx);
}
fn stop_recording(&mut self, cx: &mut Context<Self>) {
self.record_gen = self.record_gen.wrapping_add(1);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
}
cx.notify();
}
fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) {
// Compared as chords, not as spellings: a recorded `secondary-}` and a
// config's `secondary-shift-]` are one keystroke written two ways, and
// only `same_chord` sees it. Compared as text, the displacement never
// fires and both bindings survive onto that keystroke, where which one
// wins is arbitrary (#750).
//
// Only that chord moves: the action that had it keeps any others it
// has, since an action can carry several (#868) and emptying it would
// take away keys that were never on this keystroke. An extra default —
// Alt+Enter beside Shift+Enter — follows its action's first chord rather
// than being one of its own, so its owner is unbound outright, as it
// always was.
use crate::ui::keymap::same_chord;
let displaced: Option<(String, Vec<String>)> = crate::ui::keymap::effective_chords(cx)
.into_iter()
.find(|(a, chords)| *a != action && chords.iter().any(|k| same_chord(k, &spec)))
.map(|(a, chords)| {
let rest = chords.into_iter().filter(|k| !same_chord(k, &spec));
(a, rest.collect())
})
.or_else(|| {
crate::ui::keymap::extra_bindings(cx)
.into_iter()
.find(|(a, k)| *a != action && same_chord(k, &spec))
.map(|(a, _)| (a, Vec::new()))
});
// A trailing "…" on an action name marks a command that opens
// something; it is not punctuation, and inside a sentence it reads as
// the sentence trailing off — "Rename Tab… took the shortcut from".
let in_prose = |name: &str| name.trim_end_matches('…').to_string();
let note = displaced.as_ref().map(|(other, _)| {
t_fmt(
L10nKey::AppKeybindingDisplacedNote,
&[
(
"action",
&in_prose(&crate::ui::keymap::action_entry(&action).1),
),
(
"previous",
&in_prose(&crate::ui::keymap::action_entry(other).1),
),
],
)
});
// Both written as lists. Recording a shortcut sets it — the row showed
// one chord and now shows another — and a bare string in config adds a
// chord beside the default instead (#868).
self.update_config(cx, |cfg| {
use crate::core::config::KeybindingOverride;
if let Some((other, rest)) = &displaced {
cfg.keybindings
.insert(other.clone(), KeybindingOverride::Exact(rest.clone()));
}
cfg.keybindings
.insert(action, KeybindingOverride::Exact(vec![spec]));
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.rebinding_note = note;
}
cx.notify();
}
/// Takes every chord off an action, and keeps it off.
///
/// ⌫ on a row that has recorded nothing used to *reset* it — drop the
/// override so the action gets its shipped chord back. On a row nobody has
/// overridden, which is every row the first time it is looked at, that is a
/// no-op: someone pressing Backspace over Alt+1 to be rid of it watched
/// Alt+1 sit exactly where it was and read it as the default restoring
/// itself (#901). Nothing anywhere in the app said "this action should have
/// no key", though `config.json` has spelled it `[]` since #868.
///
/// So ⌫ writes that empty list, and the **Reset** button beside the row —
/// which appears the moment an action is overridden, this way included — is
/// the way back to the default.
pub(crate) fn unbind_keybinding(&mut self, action: String, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.keybindings.insert(
action,
crate::core::config::KeybindingOverride::Exact(Vec::new()),
);
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.keybindings.remove(&action);
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
pub(crate) fn restore_default_keybindings(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Nothing here is recoverable: the overrides are dropped from config
// and the only record of them was the config.
if cx.global::<Config>().keybindings.is_empty() {
return;
}
let answer = window.prompt(
PromptLevel::Warning,
t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaults),
Some(t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaultsBody)),
&crate::ui::confirm_answers(
t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaults),
t(crate::ui::i18n::L10nKey::Cancel),
),
cx,
);
cx.spawn_in(window, async move |this, cx| {
let Ok(0) = answer.await else { return };
let _ = this.update(cx, |this, cx| {
this.restore_default_keybindings_confirmed(cx)
});
})
.detach();
}
fn restore_default_keybindings_confirmed(&mut self, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.keybindings.clear());
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
pub(crate) fn set_keybinding_preset(&mut self, preset: &str, cx: &mut Context<Self>) {
let preset = preset.to_string();
self.update_config(cx, |cfg| cfg.keybinding_preset = preset);
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
pub(crate) fn set_keybinding_prefix(&mut self, prefix: &str, cx: &mut Context<Self>) {
let prefix = prefix.to_string();
self.update_config(cx, |cfg| cfg.prefix = prefix);
crate::ui::keymap::rebind(cx);
cx.notify();
}
#[allow(dead_code)]
pub(crate) fn open_config_file(&self, cx: &Context<Self>) {
let Some(path) = crate::core::config::config_path("config.json") else {
return;
};
if !path.exists() {
cx.global::<Config>().save();
}
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
log::warn!("failed to open {}: {e}", path.display());
}
}
}
#[cfg(test)]
pub(crate) mod render_probe {
use std::cell::Cell;
thread_local! {
static DRAWS: Cell<u64> = const { Cell::new(0) };
static BUDGET: Cell<Option<u64>> = const { Cell::new(None) };
}
pub(crate) fn record() {
let n = DRAWS.get() + 1;
DRAWS.set(n);
if let Some(budget) = BUDGET.get()
&& n > budget
{
BUDGET.set(None);
panic!(
"the window drew more than {budget} frames without input: it never reached \
render idle (issue #243)"
);
}
}
pub(crate) fn arm(budget: u64) {
DRAWS.set(0);
BUDGET.set(Some(budget));
}
pub(crate) fn draws() -> u64 {
DRAWS.get()
}
}
impl Tty7App {
fn render_remote_workspace_strip(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
if self.tabs.is_empty() {
return None;
}
let status = self.remote_status(cx)?;
let machine = self.remote_machine_label(cx);
let message = status.strip_message(&machine)?;
// While an install runs, the strip is that install's progress: the
// refusal it is answering is no longer the news, and the button that
// started it would only start a second one.
let installing = self.remote_strip_progress(cx);
let action = installing
.is_none()
.then(|| self.remote_strip_action(&status, cx))
.flatten();
let theme = cx.theme();
let message = match installing {
Some(phase) => format!(
"{machine} — {}",
crate::ui::remote_workspace::install_phase_caption(phase)
),
None => message,
};
let bar = crate::ui::remote_workspace::status_card(cx)
.occlude()
.shadow_md()
.child(
crate::ui::remote_workspace::status_row()
.child(gpui_component::Icon::new(gpui_component::IconName::Globe))
.child(
crate::ui::remote_workspace::status_message(message)
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.foreground),
)
.when_some(action, |this, (label, action)| {
use gpui_component::Sizable as _;
use gpui_component::button::ButtonVariants as _;
this.child(
gpui_component::button::Button::new("remote-status-action")
.flex_shrink_0()
.label(label)
.primary()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.run_strip_action(action.clone(), window, cx);
})),
)
}),
)
.when_some(installing, |this, phase| {
this.child(crate::ui::remote_workspace::install_progress_bar(phase, cx))
});
Some(
div()
.absolute()
.top_2()
.left_0()
.right_0()
.flex()
.justify_center()
.child(bar)
.into_any_element(),
)
}
fn render_remote_input_notice(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
if self.tabs.is_empty() {
return None;
}
let notice = self.remote_status(cx)?.input_notice()?;
// The pill only. `body_area` anchors it, together with whatever else
// is floating down there — see `ui::notice`.
Some(
crate::ui::notice::pill(cx.theme().warning, cx)
.child(notice)
.into_any_element(),
)
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ForwardRoute {
pane_id: u64,
workspace: Option<crate::terminal::PaneWorkspace>,
}
/// What came of asking the far side to put one forward up.
enum PlaceOutcome {
Placed,
/// The far side answered and the rule could not be started — a bind that
/// collided, a port this process may not have. Another bind port might.
Rejected(String),
/// Nobody answered. Nothing was bound, and nothing about the rule is what
/// went wrong.
Unreachable(String),
}
/// Who a set of forwards belongs to on the far side.
///
/// A workspace's forwards outlive any one of its panes and are shared between
/// all of them, so "have we already offered to forward :3000" is a question
/// about the workspace — asking it per pane made switching tabs re-announce
/// every port the workspace had already forwarded.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum ForwardOwnerKey {
Pane(u64),
Workspace(crate::core::session::WorkspaceId),
}
impl ForwardRoute {
pub(crate) fn new(pane_id: u64, workspace: Option<crate::terminal::PaneWorkspace>) -> Self {
Self { pane_id, workspace }
}
/// Which side of the daemon's own forward registry this route addresses —
/// the same split `ForwardOwner` makes there.
pub(crate) fn owner_key(&self) -> ForwardOwnerKey {
match &self.workspace {
Some(ws) => ForwardOwnerKey::Workspace(ws.workspace),
None => ForwardOwnerKey::Pane(self.pane_id),
}
}
fn workspace_op(
&self,
op: crate::daemon::protocol::WorkspaceOp,
) -> Option<crate::daemon::protocol::WorkspaceRequest> {
crate::terminal::RemoteTerminal::workspace_request(
self.workspace.as_ref()?,
self.pane_id,
op,
)
}
/// The list a forward request answered with, or `None` when it did not
/// answer at all.
///
/// The two are not the same and the panel has to be able to tell them
/// apart: an empty list is a pane with no forwards left, while a request
/// that failed says nothing about what the far side still has. Reporting
/// the second as the first is what blanked the panel whenever the daemon
/// was briefly unreachable.
fn forwards(
reply: anyhow::Result<crate::daemon::protocol::DaemonMsg>,
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
match reply {
Ok(crate::daemon::protocol::DaemonMsg::ForwardList(list)) => Some(list),
Ok(other) => {
log::warn!("unexpected reply to a workspace forward request: {other:?}");
None
}
Err(e) => {
log::warn!("a workspace forward request failed: {e}");
None
}
}
}
pub(crate) fn list(&self) -> Vec<crate::daemon::protocol::ManagedForward> {
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::ListForwards)
else {
return crate::terminal::RemoteTerminal::list_forwards(self.pane_id);
};
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)).unwrap_or_default()
}
pub(crate) fn add(
&self,
rule: crate::daemon::protocol::SshForwardRule,
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
let Some(req) = self
.workspace_op(crate::daemon::protocol::WorkspaceOp::AddForward { rule: rule.clone() })
else {
return crate::terminal::RemoteTerminal::add_forward(self.pane_id, rule);
};
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req))
}
pub(crate) fn teardown(&self) -> Vec<crate::daemon::protocol::ManagedForward> {
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::TeardownForwards)
else {
return Vec::new();
};
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)).unwrap_or_default()
}
/// The local port that reaches `remote_host:remote_port` over this route,
/// building the forward if there is not one yet.
///
/// The far side keeps one automatic forward per endpoint and hands the
/// same port back on the next ask, so callers may treat this as "what is
/// the address here" rather than as an action with a cost — which is what
/// lets the Ports list call it on a click and the watcher call it on a
/// port it has only just noticed.
pub(crate) fn ensure_loopback(
&self,
remote_host: &str,
remote_port: u16,
) -> anyhow::Result<crate::daemon::protocol::LoopbackForward> {
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::EnsureLoopback {
remote_host: remote_host.to_string(),
remote_port,
}) else {
return crate::terminal::RemoteTerminal::ensure_loopback_forward(
self.pane_id,
remote_host,
remote_port,
);
};
match crate::terminal::RemoteTerminal::on_workspace(req)? {
crate::daemon::protocol::DaemonMsg::LoopbackForward(f) => Ok(f),
other => Err(anyhow::anyhow!(
"unexpected reply to EnsureLoopback: {other:?}"
)),
}
}
pub(crate) fn remove(
&self,
forward_id: u64,
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
let Some(req) =
self.workspace_op(crate::daemon::protocol::WorkspaceOp::RemoveForward { forward_id })
else {
return crate::terminal::RemoteTerminal::remove_forward(self.pane_id, forward_id);
};
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req))
}
}
impl Render for Tty7App {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
#[cfg(test)]
render_probe::record();
let prof = crate::ui::perf::enabled().then(std::time::Instant::now);
// Every window's root is this view, so setting the rem here is what
// makes `ui_font_size` reach the whole interface — the rem ladder
// (`text_sm`, `text_xs`, `rems(..)`) resolves against it, and the
// terminal grid, sized in absolute px from `font_size`, does not move.
window.set_rem_size(px(cx.global::<Config>().ui_font_size));
self.claim_pending_tab(window, cx);
self.touch_active_tab();
self.declare_displayed_panes(cx);
self.scm_sync_watchers(window, cx);
// Keeps looking for new listening ports on the pane in front, panel
// open or not — a port that appears while the panel is shut is exactly
// the one worth forwarding unasked.
self.sync_port_watch(window, cx);
if cx.has_active_drag() {
crate::ui::reorder::clear_pending(&self.reorder);
crate::ui::pane_drag::clear_landing(&self.pane_drag);
} else {
// Taken first either way: this is what ends the drag, and the merge
// below must not find the tab it just moved still in the air.
let landed = crate::ui::reorder::take_landed(&self.reorder);
if let Some((tab, zone)) = self.tab_merge.take() {
self.merge_tab(tab, zone, window, cx);
} else if let Some((tab, key)) = landed.regroup {
// A drop into another group outranks the reordering the drag
// did on its way out of the one it came from. The pointer
// left that group; the shuffle it caused before leaving is
// not what was being asked for.
self.regroup_tab(tab, key, cx);
} else if let Some(order) = landed.order {
self.apply_tab_order(&order, cx);
}
// Also what ends the pane drag, so it is taken whichever of the two
// readings the last frame left behind.
let landing = crate::ui::pane_drag::take_landing(&self.pane_drag);
if let Some((pane, at)) = self.pane_detach.take() {
self.detach_pane(pane, at, window, cx);
} else if let Some((from, zone)) = landing {
self.drop_pane(from, zone, window, cx);
}
}
// Windows has no closed-hand cursor and gpui answers `ClosedHand` with
// the plain arrow there, which would drop the grip's pointing hand the
// instant the drag it advertised began.
let held = if cfg!(target_os = "windows") {
gpui::CursorStyle::PointingHand
} else {
gpui::CursorStyle::ClosedHand
};
if (self.reorder.borrow().is_some() || self.pane_drag.borrow().is_some())
&& cx.active_drag_cursor_style() != Some(held)
{
cx.set_active_drag_cursor_style(held, window);
}
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
// Asked through the predicate the right panel sizes itself against —
// two spellings of "is the rail up" is one more than the layout can
// afford to have disagree.
let rail = self.sidebar_open(cx);
// Both read before the strip and the sidebar are built: a tab held out
// over the layout suspends the reorder, which is what those two ask
// what to draw, and a pane held over *them* is measured against where
// they put their tabs last frame — which is what they are about to
// blank and write again.
let tab_landing = self.tab_landing(window, cx);
let detach_caret = self.detach_caret(window, cx);
let strip = self.tab_strip(!vertical, window, cx);
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
let ssh_status = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.and_then(|leaf| self.render_ssh_status_strip(&leaf, cx));
let body = match self.tabs.get(self.active) {
None => self.render_home(cx).into_any_element(),
Some(active_tab) => {
let maximized = self.maximized.as_ref().filter(|leaf| {
active_tab
.pane
.leaves()
.iter()
.any(|l| l.entity_id() == leaf.entity_id())
});
match maximized {
Some(leaf) => div()
.size_full()
.overflow_hidden()
.child(leaf.clone())
.into_any_element(),
None => {
let several = active_tab.pane.leaves().len() > 1;
let chrome = crate::ui::pane::PaneChrome {
dim_inactive: several && cx.global::<Config>().dim_inactive_panes,
rearrangeable: several,
hovered: self.pane_hover.clone(),
lifted: crate::ui::pane_drag::lifted(&self.pane_drag),
drag: self.pane_drag.clone(),
};
active_tab.pane.render(&chrome, window, cx)
}
}
}
};
// No window buttons in fullscreen, where they cannot work: the window
// has no caption for the platform to hit-test, so they would draw,
// light up under the pointer and do nothing when clicked. `TitleBar`
// always draws them, so the strip goes into a plain row of the same
// geometry instead — the row itself stays, since it holds the tabs,
// the chrome tiles and the docked document's header.
let title_bar =
if window_controls_drawn(window.is_fullscreen()) || cfg!(target_os = "macos") {
TitleBar::new()
.h(px(TITLE_BAR_HEIGHT))
.bg(cx.theme().transparent)
.border_color(cx.theme().transparent)
.child(strip)
.into_any_element()
} else {
div()
.flex_shrink_0()
.flex()
.flex_row()
.items_center()
.h(px(TITLE_BAR_HEIGHT))
.pl(px(TITLE_BAR_LEAD))
.border_b_1()
.border_color(cx.theme().transparent)
.child(
div()
.flex()
.flex_row()
.items_center()
.h_full()
.flex_1()
.child(strip),
)
.into_any_element()
};
let body_area = div()
.flex_1()
.relative()
.overflow_hidden()
.child(
gpui::canvas(
{
let area = self.pane_area.clone();
move |bounds, _window, _cx| area.set(Some(bounds))
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
)
.child(body)
.when_some(self.pane_landing(window, cx), |this, el| this.child(el))
.when_some(tab_landing, |this, el| this.child(el))
.when_some(self.render_remote_workspace_strip(cx), |this, el| {
this.child(el)
})
// Both of these used to anchor themselves at `bottom_4` and centre
// themselves, as siblings here — so a remote workspace whose ssh
// link had also dropped drew them one on top of the other. One
// anchor now, and it stacks. The ssh strip goes last because it is
// the one carrying buttons.
.when_some(
crate::ui::notice::anchor(
[self.render_remote_input_notice(cx), ssh_status]
.into_iter()
.flatten()
.collect(),
),
|this, el| this.child(el),
);
// One decision for the whole document surface. Docked, exactly one of
// the two surfaces is drawn — a column has one child, and two `flex_1`
// siblings would split it and fight — so `overlay_top` stops ordering a
// pair and starts choosing between them. Filling, nothing changes: both
// are rendered, ordered by `overlay_top`, and the front one wins on
// paint order as it always has.
let document_dock_px = self.document_dock_px(window, cx);
// Where the docked header sits. Everywhere but macOS the title bar
// spans the workspace and leaves the strip above the column empty, so
// the header goes up into it; on macOS the column already reaches the
// top of the window and its own first row lands there.
let document_chrome = if cfg!(target_os = "macos") {
crate::ui::document_column::DocumentChrome::Dock
} else {
crate::ui::document_column::DocumentChrome::DockHoisted
};
let document_header = document_dock_px
.is_some()
.then(|| self.render_document_header(document_chrome, window, cx))
.flatten();
let (overlays, document_column) = match document_dock_px {
Some(w) => (
Vec::new(),
self.render_document_column(w, document_chrome, window, cx),
),
None => {
let diff_overlay = self.render_diff_overlay(
crate::ui::document_column::DocumentChrome::Fill,
window,
cx,
);
let code_overlay = self.render_code_overlay(
crate::ui::document_column::DocumentChrome::Fill,
window,
cx,
);
let mut pair = vec![
(OverlayTop::Diff, diff_overlay),
(OverlayTop::Code, code_overlay),
];
if self
.tabs
.get(self.active)
.is_some_and(|t| t.overlay_top == OverlayTop::Diff)
{
pair.reverse();
}
(
pair.into_iter()
.filter_map(|(_, el)| el)
.collect::<Vec<gpui::AnyElement>>(),
None,
)
}
};
let document_px = document_column
.as_ref()
.map_or(0., |_| document_dock_px.unwrap_or_default());
let right_panel = self.render_right_panel(window, cx);
// A docked document takes the same fork the right panel does: on
// Windows and Linux the window controls live at the right end of the
// title bar, so the bar has to span the workspace rather than sit
// inside the terminal column with a column drawn to the right of it.
let panel_below_title_bar =
(right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos");
let (column_title_bar, spanning_title_bar) = if panel_below_title_bar {
(None, Some(title_bar))
} else {
(Some(title_bar), None)
};
let (column_overlays, hoisted_overlays) = if panel_below_title_bar {
(Vec::new(), overlays)
} else {
(overlays, Vec::new())
};
let panel_px = if right_panel.is_some() {
self.right_panel_px(window, cx)
} else {
0.
};
let terminal_column = div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.relative()
.when_some(column_title_bar, |this, bar| this.child(bar))
.child(body_area)
.children(column_overlays);
let panel_row = div()
.flex_1()
.min_h_0()
.min_w_0()
.flex()
.flex_row()
.child(terminal_column)
.when_some(document_column, |this, column| this.child(column))
.when_some(right_panel, |this, panel| this.child(panel));
let main_layout = div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_row()
.when_some(sidebar, |this, sidebar| this.child(sidebar))
.child(match spanning_title_bar {
Some(bar) => div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.relative()
.child(
div()
.relative()
.flex_none()
// One patch per column below, rather than one for
// both: each carries the left border its own column
// carries, so the rule between the document and the
// detail panel runs the full height of the window
// instead of stopping at the title bar.
.when(panel_px > 0., |this| {
this.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_0()
.w(px(panel_px))
.bg(crate::ui::theme::workspace_surface_color(cx))
.border_l_1()
.border_color(cx.theme().sidebar_border),
)
})
.when(document_px > 0., |this| {
this.child(
div()
.absolute()
.top_0()
.bottom_0()
.right(px(panel_px))
.w(px(document_px))
.bg(crate::ui::theme::workspace_surface_color(cx))
.border_l_1()
.border_color(cx.theme().sidebar_border),
)
})
.child(bar)
// The document's header, in the strip the spanning
// title bar leaves empty above its column. Drawn
// after the bar so it sits over the tab strip's
// slack — and stopping short of the trailing
// chrome, which is only in the way when the detail
// panel is closed and this column is the one at the
// window's right edge.
.when_some(document_header, |this, header| {
this.child(
div()
.absolute()
.top_0()
.h(px(TITLE_BAR_HEIGHT))
.right(px(panel_px))
.w(px(document_px))
.when(panel_px <= 0., |d| {
d.pr(px(crate::ui::tab_strip::trailing_chrome_w(
window.is_fullscreen(),
)))
})
.child(header),
)
}),
)
.child(panel_row)
.children(hoisted_overlays.into_iter().map(|overlay| {
div()
.absolute()
.top_0()
.left_0()
.bottom_0()
.right(px(panel_px))
.child(overlay)
}))
.into_any_element(),
None => panel_row.into_any_element(),
})
.into_any_element();
let window_bg = crate::ui::theme::workspace_background(cx);
let bg_image = window_background_image_layer(cx);
let settings_bg = crate::ui::theme::overlay_background(cx);
let settings_overlay = self.settings.is_some().then(|| {
div()
.absolute()
.inset_0()
.occlude()
// Opaque on purpose: the settings panel must never let the
// workspace translucency (window opacity / backdrop material)
// show through, even at window edges during a resize. The
// preset's gradient fill is preserved, just with alpha 1;
// `render_settings` repaints the theme image over it.
.bg(settings_bg)
.child(self.render_settings(window, cx))
});
let root =
div()
.id("tty7-root")
.size_full()
.flex()
.flex_col()
.bg(window_bg)
.text_color(cx.theme().foreground)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx)))
.on_action(cx.listener(|this, _: &SelectWorkspace1, window, cx| {
this.select_workspace_slot(0, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace2, window, cx| {
this.select_workspace_slot(1, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace3, window, cx| {
this.select_workspace_slot(2, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace4, window, cx| {
this.select_workspace_slot(3, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace5, window, cx| {
this.select_workspace_slot(4, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace6, window, cx| {
this.select_workspace_slot(5, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace7, window, cx| {
this.select_workspace_slot(6, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace8, window, cx| {
this.select_workspace_slot(7, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectWorkspace9, window, cx| {
this.select_workspace_slot(8, window, cx)
}))
.on_action(cx.listener(|this, _: &RenameWorkspace, window, cx| {
this.start_workspace_rename(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleSwitcher, window, cx| {
this.toggle_switcher(window, cx)
}))
.on_action(cx.listener(|this, _: &StopWorkspace, window, cx| {
let id = this.workspace;
this.stop_workspace(id, window, cx);
}))
.on_action(cx.listener(|this, _: &DeleteWorkspace, window, cx| {
let id = this.workspace;
this.delete_workspace(id, window, cx);
}))
.on_action(cx.listener(|this, _: &NewWorkspace, window, cx| {
this.open_workspace_form(window, cx);
}))
.on_action(cx.listener(|this, _: &NewWindow, _window, cx| {
this.new_window(cx);
}))
.on_action(
cx.listener(|this, _: &CloseWindow, window, cx| this.close_window(window, cx)),
)
.on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| {
if !this.editor_close_active_if_focused(window, cx) {
this.close_pane(window, cx)
}
}))
.on_action(cx.listener(|this, _: &SplitRight, window, cx| {
this.split(Axis::Horizontal, window, cx)
}))
.on_action(cx.listener(|this, _: &SplitDown, window, cx| {
this.split(Axis::Vertical, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusNextPane, window, cx| {
this.cycle_pane(true, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPrevPane, window, cx| {
this.cycle_pane(false, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneLeft, window, cx| {
this.focus_pane_dir(Dir::Left, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneRight, window, cx| {
this.focus_pane_dir(Dir::Right, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneUp, window, cx| {
this.focus_pane_dir(Dir::Up, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| {
this.focus_pane_dir(Dir::Down, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneLeft, window, cx| {
this.resize_pane(Dir::Left, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneRight, window, cx| {
this.resize_pane(Dir::Right, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneUp, window, cx| {
this.resize_pane(Dir::Up, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneDown, window, cx| {
this.resize_pane(Dir::Down, window, cx)
}))
.on_action(cx.listener(|this, _: &SwapPaneNext, window, cx| {
this.swap_pane(true, window, cx)
}))
.on_action(cx.listener(|this, _: &SwapPanePrev, window, cx| {
this.swap_pane(false, window, cx)
}))
.on_action(
cx.listener(|this, _: &NextTab, window, cx| this.tab_switch(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &PrevTab, window, cx| this.tab_switch(false, window, cx)),
)
.on_action(cx.listener(|this, _: &SelectNextTab, window, cx| {
this.cycle_tab(true, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectPrevTab, window, cx| {
this.cycle_tab(false, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab1, window, cx| {
this.activate_visual(0, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab2, window, cx| {
this.activate_visual(1, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab3, window, cx| {
this.activate_visual(2, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab4, window, cx| {
this.activate_visual(3, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab5, window, cx| {
this.activate_visual(4, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab6, window, cx| {
this.activate_visual(5, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab7, window, cx| {
this.activate_visual(6, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab8, window, cx| {
this.activate_visual(7, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab9, window, cx| {
this.activate_visual(8, window, cx)
}))
.on_action(cx.listener(|this, _: &IncreaseFontSize, _window, cx| {
this.change_font_size(FONT_SIZE_STEP, cx)
}))
.on_action(cx.listener(|this, _: &DecreaseFontSize, _window, cx| {
this.change_font_size(-FONT_SIZE_STEP, cx)
}))
.on_action(
cx.listener(|this, _: &ResetFontSize, _window, cx| this.reset_font_size(cx)),
)
.on_action(cx.listener(|this, _: &TogglePalette, window, cx| {
this.toggle_palette(window, cx)
}))
.on_action(cx.listener(|this, _: &ReopenClosedTab, window, cx| {
this.reopen_closed_tab(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleMaximizePane, window, cx| {
this.toggle_maximize(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleFullscreen, window, cx| {
this.toggle_fullscreen(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleTabSidebar, _window, cx| {
this.toggle_tab_sidebar(cx)
}))
.on_action(
cx.listener(|this, _: &ToggleLeftPanel, _window, cx| {
this.toggle_left_panel(cx)
}),
)
.on_action(cx.listener(|this, _: &ToggleRightPanel, _window, cx| {
this.toggle_right_panel(cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelInfo, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Scm, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
}))
.on_action(
cx.listener(|this, _: &ScmToggleGraph, _window, cx| this.scm_toggle_graph(cx)),
)
.on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| {
this.toggle_diff_view_mode(cx)
}))
.on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| {
this.toggle_document_fill(cx)
}))
.on_action(cx.listener(|this, _: &DocumentWidthThird, _window, cx| {
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
}))
.on_action(cx.listener(|this, _: &DocumentWidthHalf, _window, cx| {
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
}))
.on_action(
cx.listener(|this, _: &DocumentWidthTwoThirds, _window, cx| {
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
}),
)
.on_action(cx.listener(|this, _: &ScmCommit, window, cx| {
this.run_scm_action(ScmIntent::Commit, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCommitAmend, window, cx| {
this.run_scm_action(ScmIntent::CommitAmend, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmStageAll, window, cx| {
this.run_scm_action(ScmIntent::StageAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmUnstageAll, window, cx| {
this.run_scm_action(ScmIntent::UnstageAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmDiscardAll, window, cx| {
this.run_scm_action(ScmIntent::DiscardAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmRefresh, window, cx| {
this.run_scm_action(ScmIntent::Refresh, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmSync, window, cx| {
this.run_scm_action(ScmIntent::Sync, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmPush, window, cx| {
this.run_scm_action(ScmIntent::Push, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmPull, window, cx| {
this.run_scm_action(ScmIntent::Pull, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmFetch, window, cx| {
this.run_scm_action(ScmIntent::Fetch, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCheckoutBranch, window, cx| {
this.run_scm_action(ScmIntent::CheckoutBranch, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCreateBranch, window, cx| {
this.run_scm_action(ScmIntent::CreateBranch, window, cx)
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
this.toggle_settings(window, cx)
}))
.on_action(cx.listener(|this, _: &RestartDaemon, window, cx| {
this.restart_window_daemon(window, cx)
}))
.on_action(
cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx)),
)
.on_action(cx.listener(|this, _: &ShowSshForwards, window, cx| {
this.show_ssh_forwards(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleCodePanel, window, cx| {
this.toggle_code_panel(window, cx)
}))
.on_action(cx.listener(|this, _: &EditorSave, window, cx| {
if !this.editor_has_focus(window, cx) {
cx.propagate();
return;
}
this.editor_save_active(window, cx)
}))
.on_action(
cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)),
)
.on_action(cx.listener(|this, _: &OpenSshProfiles, window, cx| {
this.open_settings_section(SettingsSection::Ssh, window, cx)
}))
.on_action(cx.listener(|this, _: &RestartSshSession, window, cx| {
this.restart_ssh_session(window, cx)
}))
.on_action(cx.listener(|this, _: &RenameTab, window, cx| {
this.start_rename(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &NewWorktreeTab, window, cx| {
this.new_worktree_tab(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &CloseOtherTabs, window, cx| {
this.close_other_tabs(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &CloseTabsToTheRight, window, cx| {
this.close_tabs_right_of(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &CopyWorkingDirectory, window, cx| {
this.copy_active_cwd(window, cx)
}))
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
this.mark_tab_unread(this.active, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| {
this.fork_active_pane_session(ForkPlacement::NewTab, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionRight, window, cx| {
this.fork_focused_pane_session(Axis::Horizontal, false, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionLeft, window, cx| {
this.fork_focused_pane_session(Axis::Horizontal, true, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionDown, window, cx| {
this.fork_focused_pane_session(Axis::Vertical, false, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionUp, window, cx| {
this.fork_focused_pane_session(Axis::Vertical, true, window, cx)
}))
.on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| {
this.copy_agent_session_id(this.active, window, cx)
}))
.on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| {
this.open_settings_section(SettingsSection::Keybindings, window, cx)
}))
.on_action(cx.listener(|this, _: &About, window, cx| {
this.open_settings_section(SettingsSection::About, window, cx)
}))
.on_action(cx.listener(|this, _: &CheckForUpdates, window, cx| {
this.check_for_updates_now(window, cx)
}))
.on_action(cx.listener(|_, _: &HideApp, _window, cx| cx.hide()))
.on_action(cx.listener(|_, _: &HideOthers, _window, cx| cx.hide_other_apps()))
.on_action(cx.listener(|_, _: &ShowAll, _window, cx| cx.unhide_other_apps()))
.on_action(
cx.listener(|_, _: &MinimizeWindow, window, _cx| window.minimize_window()),
)
.on_action(cx.listener(|_, _: &ZoomWindow, window, _cx| window.zoom_window()))
.on_action(
cx.listener(|_, _: &OpenDocumentation, _window, cx| cx.open_url(DOCS_URL)),
)
.on_action(cx.listener(|_, _: &OpenDiscord, _window, cx| cx.open_url(DISCORD_URL)))
.on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL)))
.children(bg_image)
.child(main_layout)
// Window-level because the strip lives in the title bar and the
// sidebar down the side: the caret between two tabs is in
// neither of the boxes the rest of the drag feedback is drawn
// in.
.when_some(detach_caret, |this, caret| this.child(caret))
.when_some(settings_overlay, |this, overlay| this.child(overlay))
// Window-level, like the switcher and the palette: the prompt
// blocks the whole app, so its scrim has to reach the title bar
// and the side panels too. Parented to `body_area` it was
// clipped to the terminal area, which read as "only the
// terminal is busy".
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
this.child(el)
})
// Same reason, and the ssh prompt has more claim to it than any
// of them: nothing in the window can proceed until the password
// is answered, so the scrim has to cover the whole window and
// not stop at the terminal area.
.when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| {
this.child(el)
})
.children(self.render_switcher(window, cx))
.when_some(self.palette.clone(), |this, palette| this.child(palette))
.children(gpui_component::Root::render_notification_layer(window, cx));
if let Some(start) = prof {
crate::ui::perf::record("window", start.elapsed());
}
root
}
}
/// Orders tab indices most-recently-used first from their `last_used` stamps.
/// A zero stamp means the tab was never activated, and those keep strip order
/// at the back. `active` leads regardless — its own stamp only lands on the
/// next frame.
/// The tab after (or before) `active` in `order`, wrapping round. `None`
/// when there is nowhere else to go; a tab missing from `order` starts from
/// its end, so the step still lands on a tab the user can see.
fn step_in_order(order: &[usize], active: usize, forward: bool) -> Option<usize> {
let n = order.len();
if n < 2 {
return None;
}
let pos = order.iter().position(|&i| i == active);
let next = match (pos, forward) {
(Some(p), true) => (p + 1) % n,
(Some(p), false) => (p + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
Some(order[next]).filter(|&i| i != active)
}
fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..stamps.len()).collect();
order.sort_by_key(|&i| (stamps[i] == 0, std::cmp::Reverse(stamps[i]), i));
if let Some(pos) = order.iter().position(|&i| i == active) {
let lead = order.remove(pos);
order.insert(0, lead);
}
order
}
fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
sidebar_group: tab.sidebar_group.borrow().clone(),
tree_id: None,
}
}
fn agent_resume_command(
agent: &Option<crate::core::cli_agent::CLIAgent>,
session_id: Option<&str>,
launch_argv: Option<&[String]>,
cx: &App,
) -> Option<String> {
if !cx.global::<Config>().restore_agent_sessions {
return None;
}
let agent = agent.as_ref()?;
let Some(session_id) = session_id else {
log::info!(
"{}'s pane had no captured session id; it comes back as a plain shell",
agent.display_name()
);
return None;
};
agent.resume_command(session_id, launch_argv)
}
fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
match pane {
Pane::Leaf(PaneSlot::Connecting(pending)) => {
let spawn = &pending.read(cx).spawn;
SessionPane::Leaf {
cwd: spawn.working_directory.clone(),
pane_id: spawn.restore_pane,
shell: spawn.shell.clone(),
ssh_spec: None,
agent: spawn.agent,
agent_session_id: spawn.agent_session_id.clone(),
agent_launch_argv: spawn.agent_launch_argv.clone(),
}
}
Pane::Leaf(PaneSlot::Ready(view)) => {
let view = view.read(cx);
SessionPane::Leaf {
cwd: view.spawnable_cwd(),
pane_id: Some(view.pane_id),
// `None` for a pane this window attached to rather than
// spawned: it never knew what was on the other end. The tree
// does — the daemon records it — and that is what a restore
// reads, so the gap here costs nothing it can see.
shell: view.shell_spec(),
ssh_spec: view.ssh_spec(),
agent: view.agent(),
agent_session_id: view.agent_session().and_then(|s| s.session_id),
agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv),
}
}
Pane::Split {
axis, a, b, ratio, ..
} => SessionPane::Split {
axis: match axis {
Axis::Horizontal => SessionAxis::Horizontal,
Axis::Vertical => SessionAxis::Vertical,
},
ratio: ratio.get(),
a: Box::new(pane_to_session(a, cx)),
b: Box::new(pane_to_session(b, cx)),
},
Pane::Empty => SessionPane::Leaf {
cwd: None,
pane_id: None,
shell: None,
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
},
}
}
/// The daemon's account of which panes are alive, or `None` when it could not
/// be asked at all.
///
/// The distinction is the point: a pane absent from a *successful* listing is
/// genuinely gone and may be respawned, while a failed `List` says nothing
/// about any pane. Flattening the failure into an empty map made one transient
/// RPC error read as "every pane is dead", and the restore then spawned fresh
/// shells over all of them — the same destruction-by-inference this file's
/// restore path is built to avoid.
pub(crate) fn alive_panes_on(
route: &crate::terminal::PaneRoute,
) -> Option<std::collections::HashMap<u64, Option<String>>> {
if !matches!(route, crate::terminal::PaneRoute::Local) {
return Some(std::collections::HashMap::new());
}
match crate::terminal::RemoteTerminal::try_list_panes_on(route) {
Ok(list) => Some(
list.into_iter()
.filter(|p| p.alive)
.map(|p| (p.pane_id, p.owner))
.collect(),
),
Err(e) => {
log::warn!("could not list panes ({e}); leaving each attach to decide");
None
}
}
}
/// Whether this window may stand on `id` — as the pane it attaches to, or as
/// the dead predecessor whose screen a fresh pane opens showing.
///
/// Liveness deliberately does not come into it, and that is the whole point.
/// A pane missing from the listing is usually one whose daemon has just
/// restarted, which is exactly when its stored screen is worth asking for.
/// Ruling the id out there threw away the only thing that could ask: the
/// window spawned a pane that had never heard of a predecessor, so no attach
/// was tried, no restore was requested, and the screen the daemon still had on
/// disk was swept a tick later, unread.
///
/// There used to be a second predicate here that also required the id to be
/// listed, and the attach site consulted it. Nothing does now: the attach is
/// simply tried, and a pane that really is gone fails it and falls through to
/// the fresh spawn — the same outcome the listing was consulted to predict,
/// reached by asking the daemon instead of guessing ahead of it.
///
/// Ownership does come into it. Another workspace's pane is not this window's
/// to attach to, and its screen is not this window's to show.
fn pane_free_for(
alive: Option<&std::collections::HashMap<u64, Option<String>>>,
id: u64,
owner: crate::core::session::WorkspaceId,
) -> bool {
let Some(alive) = alive else {
// No listing to consult. Attaching is the safe guess in both
// directions: if the daemon is really unreachable the attach fails and
// the pane falls to the fresh-spawn path anyway, while spawning fresh
// on a hunch destroys a session that was merely hard to reach.
return true;
};
match alive.get(&id) {
None => true,
Some(None) => true,
Some(Some(recorded)) => {
// Only a workspace id is a claim. Anything else is a client
// stamping its own name — `tty7` before this release wrote a
// literal "tty7-cli" — and refusing on it strands every pane the
// CLI ever made, respawning over a live shell the tree just told
// us belongs here.
let Ok(recorded) = recorded.parse::<crate::core::session::WorkspaceId>() else {
return true;
};
let ours = recorded == owner;
if !ours {
log::warn!(
"restore: pane {id} is owned by workspace {recorded}, not {owner}; \
spawning fresh instead of attaching to it"
);
}
ours
}
}
}
fn tabs_from_session(
workspace: Option<&crate::terminal::PaneWorkspace>,
owner: WorkspaceId,
session: Option<Session>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> (Vec<Tab>, usize, usize) {
let Some(session) = session.filter(|s| !s.tabs.is_empty()) else {
return (Vec::new(), 0, 0);
};
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace));
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
let mut dropped = 0usize;
for st in &session.tabs {
let Some(pane) = session_to_pane(
workspace,
owner,
&st.pane,
alive.as_ref(),
font_size,
window,
cx,
) else {
// The layout coming back is the product's headline claim, so a tab
// that quietly does not is worth a sentence rather than a log line.
log::error!("dropping a restored tab: no pane in it could be started");
dropped += 1;
continue;
};
tabs.push(Tab {
pane,
name: st.name.clone(),
last_focused: None,
zoomed: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
tree_id: std::cell::Cell::new(
st.tree_id
.unwrap_or_else(tty7_core::core::machine::TabId::new),
),
last_used: std::cell::Cell::new(0),
focus_origin: Default::default(),
});
}
let active = session.active.min(tabs.len().saturating_sub(1));
(tabs, active, dropped)
}
fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool {
!(window_is_remote && leaf_is_native_ssh)
}
fn session_to_pane(
workspace: Option<&crate::terminal::PaneWorkspace>,
owner: WorkspaceId,
sp: &SessionPane,
alive: Option<&std::collections::HashMap<u64, Option<String>>>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Option<Pane> {
match sp {
SessionPane::Leaf {
cwd,
pane_id,
shell,
ssh_spec,
agent,
agent_session_id,
agent_launch_argv,
} => {
let same_daemon =
leaf_shares_the_window_daemon(workspace.is_some(), ssh_spec.is_some());
let restore = match workspace.is_some() {
true => (*pane_id).filter(|_| same_daemon),
// Not `pane_attachable`: a dead pane's id is what the restore
// is keyed on, so it has to survive being dead. The attach is
// still attempted first and still gives way to a fresh spawn.
false => (*pane_id).filter(|id| same_daemon && pane_free_for(alive, *id, owner)),
};
if restore.is_none() {
if let Some(spec) = ssh_spec.clone() {
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
Ok(view) => return Some(Pane::leaf(PaneSlot::Ready(view))),
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
}
}
}
let view = match new_terminal(
workspace.cloned(),
Some(owner),
font_size,
cwd.clone(),
restore,
shell.clone(),
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("restoring pane failed: {e}");
return None;
}
};
match &view {
PaneSlot::Ready(terminal) if !terminal.read(cx).restored() => {
if let Some(cmd) = agent_resume_command(
agent,
agent_session_id.as_deref(),
agent_launch_argv.as_deref(),
cx,
) {
terminal.read(cx).run_command_line(&cmd);
}
}
PaneSlot::Ready(_) => {}
PaneSlot::Connecting(pending) => {
pending.update(cx, |pending, _| {
pending.spawn.agent = *agent;
pending.spawn.agent_session_id = agent_session_id.clone();
pending.spawn.agent_launch_argv = agent_launch_argv.clone();
});
}
}
Some(Pane::leaf(view))
}
SessionPane::Split { axis, ratio, a, b } => {
let axis = match axis {
SessionAxis::Horizontal => Axis::Horizontal,
SessionAxis::Vertical => Axis::Vertical,
};
match (
session_to_pane(workspace, owner, a, alive, font_size, window, cx),
session_to_pane(workspace, owner, b, alive, font_size, window, cx),
) {
(Some(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)),
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
}
}
}
pub(crate) fn new_terminal(
workspace: Option<crate::terminal::PaneWorkspace>,
owner: Option<WorkspaceId>,
font_size: f32,
working_directory: Option<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> anyhow::Result<PaneSlot> {
if matches!(
crate::terminal::PaneRoute::for_workspace(workspace.as_ref()),
crate::terminal::PaneRoute::Local
) {
let parts = TerminalView::spawn_shell_terminal_in(
workspace,
working_directory,
restore_pane,
shell,
owner,
)?;
return Ok(PaneSlot::Ready(build_terminal_view(
parts, font_size, window, cx,
)));
}
let spawn = crate::ui::pending_pane::PendingSpawn {
workspace,
working_directory,
restore_pane,
shell,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
owner,
font_size,
};
// The same leak the reconnect banner had: this name is read out as
// "Connecting to {machine}…" and "Could not reach {machine}", and a
// `Profile` target spells itself as its config UUID (#485). The pane's own
// notifications already resolve it through the live config.
let machine = spawn
.workspace
.as_ref()
.map(|w| crate::ui::remote_connect::target_label(cx, &w.target))
.unwrap_or_else(|| t(L10nKey::AppLocalServerName).to_string());
let pending = cx.new(|cx| crate::ui::pending_pane::PendingPane::new(machine, spawn, cx));
cx.subscribe_in(
&pending,
window,
|_app, pending, _: &crate::ui::pending_pane::RetryRequested, window, cx| {
start_pane_spawn(pending.clone(), window, cx);
},
)
.detach();
let handle = pending.read(cx).focus_handle.clone();
watch_pane_focus(&handle, pending.entity_id(), window, cx);
start_pane_spawn(pending.clone(), window, cx);
Ok(PaneSlot::Connecting(pending))
}
fn start_pane_spawn(
pending: Entity<crate::ui::pending_pane::PendingPane>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) {
let spawn = pending.read(cx).spawn.clone();
let slot_id = pending.entity_id();
let font_size = spawn.font_size;
cx.spawn_in(window, async move |this, cx| {
let parts = cx
.background_executor()
.spawn(async move {
TerminalView::spawn_shell_terminal_in(
spawn.workspace.clone(),
spawn.working_directory.clone(),
spawn.restore_pane,
spawn.shell.clone(),
spawn.owner,
)
.map_err(|e| format!("{e:#}"))
})
.await;
let _ = this.update_in(cx, |app, window, cx| {
app.land_pane(slot_id, &pending, parts, font_size, window, cx);
});
})
.detach();
}
fn build_terminal_view(
parts: crate::terminal::view::ShellParts,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Entity<TerminalView> {
let view = cx.new(|cx| {
let mut view = TerminalView::from_shell_parts(parts, window, cx);
view.font_size = px(font_size);
view
});
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
app.on_child_exited(view.clone(), window, cx);
})
.detach();
cx.subscribe_in(
&view,
window,
|app, _view, _: &crate::terminal::view::AgentSessionChanged, _window, cx| {
app.save_session(cx);
},
)
.detach();
cx.subscribe_in(
&view,
window,
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
app.on_auth_prompt_ready(view.clone(), window, cx);
},
)
.detach();
watch_open_file_requests(&view, window, cx);
let handle = view.read(cx).focus_handle.clone();
watch_pane_focus(&handle, view.entity_id(), window, cx);
view
}
fn kill_pane_off_thread(route: crate::terminal::PaneRoute, pane_id: u64, cx: &mut App) {
cx.background_executor()
.spawn(async move { crate::terminal::RemoteTerminal::kill_pane_on(&route, pane_id) })
.detach();
}
/// Routes the file links clicked in a pane to whatever the app opens files
/// with. Every pane needs this, however it was spawned — a link in an SSH pane
/// is the same click as a link in a local one.
fn watch_open_file_requests(
view: &Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) {
cx.subscribe_in(
view,
window,
|app, _view, ev: &crate::terminal::view::OpenFileRequested, window, cx| {
app.open_linked_file(&ev.path, ev.line, ev.column, ev.is_dir, window, cx);
},
)
.detach();
}
/// Record `leaf` as the pane the tab holding it comes back to.
///
/// Which tab that is gets asked of the layout rather than assumed to be the
/// active one: a pane dragged into another tab takes focus with it, and it is
/// the tab holding it now whose memory the arrival should change. A leaf no
/// tab holds — one that has just closed, or arrived after its slot went away —
/// is recorded nowhere.
fn remember_leaf_in(tabs: &mut [Tab], leaf: gpui::EntityId) {
let held = tabs
.iter_mut()
.find(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == leaf));
if let Some(tab) = held {
tab.last_focused = Some(leaf);
}
}
/// Put `new` where the slot `old` named stood, carrying that tab's focus
/// memory across with it.
///
/// The memory has to move because the id it holds does not survive the swap.
/// Focus arriving in a pane that is still coming up is recorded against the
/// *pending* slot — that is why connecting slots are watched at all — and that
/// slot's id dies the moment the pane lands. Left behind, the memory names an
/// entity no tab holds, `focus_target` falls through `leaf_matching_or_first`,
/// and the tab comes back to its first leaf: #843 again, one landing later.
///
/// Nothing else writes the answer down in that case. `land_pane` re-focuses
/// the pane it built only when the pending slot still held focus, and with
/// focus off the panes the switch-away sample has nothing to read either.
fn replace_leaf_in(tabs: &mut [Tab], old: gpui::EntityId, new: PaneSlot) {
for tab in tabs.iter_mut() {
if tab.pane.replace_leaf(old, new.clone()) {
if tab.last_focused == Some(old) {
tab.last_focused = Some(new.entity_id());
}
break;
}
}
}
/// Repaint the chrome that marks the focused pane, and record the leaf as the
/// one its tab returns to (#843).
///
/// Every leaf is watched, connecting slots included: a pane can be focused
/// while it is still coming up, and if the tab is left in that moment the
/// answer has to already be written down.
fn watch_pane_focus(
handle: &gpui::FocusHandle,
leaf: gpui::EntityId,
window: &mut Window,
cx: &mut Context<Tty7App>,
) {
let app = cx.weak_entity();
window
.on_focus_in(handle, cx, move |_window, cx| {
if let Some(app) = app.upgrade() {
app.update(cx, |app, cx| {
app.remember_focused_leaf(leaf);
cx.notify();
});
}
})
.detach();
}
pub(crate) fn new_terminal_native(
font_size: f32,
working_directory: Option<std::path::PathBuf>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> anyhow::Result<Entity<TerminalView>> {
let parts = TerminalView::spawn_native_ssh_terminal(spec, working_directory)?;
let view = cx.new(|cx| {
let mut view = TerminalView::from_native_ssh_parts(parts, window, cx);
view.font_size = px(font_size);
view
});
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
app.on_child_exited(view.clone(), window, cx);
})
.detach();
cx.subscribe_in(
&view,
window,
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
app.on_auth_prompt_ready(view.clone(), window, cx);
},
)
.detach();
watch_open_file_requests(&view, window, cx);
let handle = view.read(cx).focus_handle.clone();
watch_pane_focus(&handle, view.entity_id(), window, cx);
Ok(view)
}
/// Reads the Settings "Shell Arguments" field as a command line (#551).
///
/// The field is one line of text standing in for the `shell.args` array, so
/// this and [`join_shell_args`] have to be exact inverses of each other — the
/// field is refilled from the array every time Settings opens, and committed
/// back on every blur. Quoting rules are the smallest set that can spell any
/// argv: single quotes take everything literally, double quotes take everything
/// literally except `\"` and `\\`, and whitespace outside quotes separates
/// words.
///
/// A lone backslash outside quotes is *not* an escape, unlike a POSIX shell:
/// these arguments are handed to the process as argv without a shell in
/// between, and `--dir C:\Users\me` has to keep its backslashes on the platform
/// where that is how a path is spelled. `#` starts no comment here either, for
/// the same reason: nothing in an argv list is a comment, and swallowing the
/// rest of the line would be the silent rewrite this whole change exists to
/// stop.
///
/// Returns `Err` when a quote never closes: that text names no argv at all.
pub(crate) fn split_shell_args(input: &str) -> Result<Vec<String>, ()> {
let mut words = Vec::new();
let mut current = String::new();
// A word can be empty and still be a word — `''` is a real argument that
// whitespace splitting cannot spell, so emptiness alone cannot end one.
let mut started = false;
let mut quote: Option<char> = None;
let mut chars = input.chars();
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
// Only inside double quotes, and only for the two characters that
// would otherwise be unspellable there.
(Some('"'), '\\') => match chars.clone().next() {
Some(next @ ('"' | '\\')) => {
chars.next();
current.push(next);
}
_ => current.push('\\'),
},
(Some(_), c) => current.push(c),
(None, '\'' | '"') => {
quote = Some(ch);
started = true;
}
(None, c) if c.is_whitespace() => {
if started {
words.push(std::mem::take(&mut current));
started = false;
}
}
(None, c) => {
current.push(c);
started = true;
}
}
}
if quote.is_some() {
return Err(());
}
if started {
words.push(current);
}
Ok(words)
}
/// Writes an argv array back into the Settings "Shell Arguments" field (#551).
///
/// The exact inverse of [`split_shell_args`], and quotes only what it must:
/// `join(" ")` cannot spell an argument that contains a space, so a legal
/// `"args": ["-c", "echo hi"]` used to refill as three words and re-commit as
/// three argv on the next blur. Arguments that need no quoting are written
/// bare, so the common `--login --color=auto` still reads as the user typed it.
pub(crate) fn join_shell_args(args: &[String]) -> String {
args.iter()
.map(|arg| quote_shell_arg(arg))
.collect::<Vec<_>>()
.join(" ")
}
fn quote_shell_arg(arg: &str) -> String {
if !arg.is_empty()
&& !arg
.chars()
.any(|c| c.is_whitespace() || c == '\'' || c == '"')
{
return arg.to_string();
}
// Single quotes need no escapes at all, so they are the first choice; an
// argument containing one falls back to double quotes, which can escape.
if !arg.contains('\'') {
return format!("'{arg}'");
}
let mut quoted = String::with_capacity(arg.len() + 2);
quoted.push('"');
for c in arg.chars() {
if c == '"' || c == '\\' {
quoted.push('\\');
}
quoted.push(c);
}
quoted.push('"');
quoted
}
/// The one rule the "Start in" custom path lives by (#601): empty means unset
/// and saves; anything else must name a directory that exists, because the
/// daemon's picker skips a path that is not one and every new pane then
/// silently starts somewhere else. Settings refuses to save such a value and
/// marks it red — both decide through this, so the red line and the not-saved
/// config always agree. Local on purpose: this is the local daemon's config.
pub(crate) fn wd_path_saveable(path: &str) -> bool {
let path = path.trim();
path.is_empty() || std::path::Path::new(path).is_dir()
}
pub(crate) fn parse_ssh_option_words(input: &str) -> Result<Vec<String>, ()> {
let mut words = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut chars = input.chars();
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
(Some(_), '\\') => {
if let Some(next) = chars.next() {
current.push(next);
}
}
(Some(_), c) => current.push(c),
(None, '\'' | '"') => quote = Some(ch),
(None, c) if c.is_whitespace() => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
(None, c) => current.push(c),
}
}
if quote.is_some() {
return Err(());
}
if !current.is_empty() {
words.push(current);
}
Ok(words)
}
pub(crate) struct ParsedSshConnect {
pub profile: crate::core::ssh_profile::SshProfile,
pub proxy_jump: Option<String>,
}
pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, String> {
use crate::core::ssh_profile::{SshProfile, parse_quick_connect};
let mut words = parse_ssh_option_words(input)
.map_err(|_| t(L10nKey::AppSshParseUnbalancedQuotes).to_string())?;
if words.first().is_some_and(|word| word == "ssh") {
words.remove(0);
}
let mut target: Option<String> = None;
let mut user: Option<String> = None;
let mut port: Option<u16> = None;
let mut identities: Vec<String> = Vec::new();
let mut jump: Option<String> = None;
let mut i = 0;
while i < words.len() {
let word = words[i].clone();
if word == "--" {
return Err(t(L10nKey::AppSshParseNoRemoteCommands).to_string());
}
if let Some((flag, attached)) = ssh_short_flag(&word) {
let value = if ssh_option_takes_value(flag) {
if !attached.is_empty() {
attached
} else {
i += 1;
match words.get(i) {
Some(v) => v.clone(),
None => {
return Err(t_fmt(
L10nKey::AppSshParseFlagNeedsValue,
&[("flag", &flag.to_string())],
));
}
}
}
} else {
String::new()
};
match flag {
'p' => {
port = Some(
value
.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| {
t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", &value)])
})?,
)
}
'l' => user = Some(value),
'i' => identities.push(value),
'J' => jump = Some(value),
'o' => apply_ssh_o_option(&value, &mut user, &mut port, &mut jump)?,
_ => {}
}
} else if word.starts_with('-') {
return Err(t_fmt(
L10nKey::AppSshParseUnsupportedOption,
&[("option", &word)],
));
} else if target.is_none() {
target = Some(word);
} else {
return Err("Remote commands aren't supported here".to_string());
}
i += 1;
}
let target = target.ok_or_else(|| t(L10nKey::AppSshParseEnterHost).to_string())?;
let qc = parse_quick_connect(&target)
.ok_or_else(|| t_fmt(L10nKey::AppSshParseBadHost, &[("host", &target)]))?;
let mut profile = SshProfile::new(qc.host.clone());
profile.host = qc.host;
profile.port = port.or(qc.port).unwrap_or(22);
if let Some(user) = user.or(qc.user) {
profile.user = user;
}
profile.identity_files = identities;
Ok(ParsedSshConnect {
profile,
proxy_jump: jump,
})
}
fn ssh_short_flag(word: &str) -> Option<(char, String)> {
let rest = word.strip_prefix('-')?;
if rest.is_empty() || rest.starts_with('-') {
return None;
}
let mut chars = rest.chars();
let flag = chars.next()?;
Some((flag, chars.as_str().to_string()))
}
fn apply_ssh_o_option(
value: &str,
user: &mut Option<String>,
port: &mut Option<u16>,
jump: &mut Option<String>,
) -> Result<(), String> {
let Some((name, val)) = value.split_once('=') else {
return Ok(());
};
match name.to_ascii_lowercase().as_str() {
"user" => *user = Some(val.to_string()),
"port" => {
*port = Some(
val.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", val)]))?,
)
}
"proxyjump" => *jump = Some(val.to_string()),
_ => {}
}
Ok(())
}
#[cfg(test)]
mod window_drag_tests {
use gpui::{
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, PlatformInput, Point, Render,
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
};
use std::cell::Cell;
use std::rc::Rc;
struct Host;
impl Render for Host {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(super::title_bar_drag(
div().id("stand-in-title-bar").w_full().h(px(40.)),
"stand-in-title-bar",
window,
cx,
))
}
}
struct HandleOverRow {
occluded: bool,
}
impl Render for HandleOverRow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let handle = div()
.absolute()
.top_0()
.left(px(296.))
.w(px(8.))
.h_full()
.cursor_col_resize()
.on_mouse_down(MouseButton::Left, |_, window, _| window.refresh());
let handle = if self.occluded {
handle.occlude()
} else {
handle
};
div()
.relative()
.size_full()
.child(super::title_bar_drag(
div().id("stand-in-title-bar").w_full().h(px(40.)),
"stand-in-title-bar",
window,
cx,
))
.child(handle)
}
}
struct PerFrameCellHost;
impl Render for PerFrameCellHost {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let should_move = Rc::new(Cell::new(false));
div().size_full().child(
div()
.id("per-frame-cell")
.w_full()
.h(px(40.))
.on_mouse_down(MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(true)
})
.on_mouse_move(move |_, window, _| {
if should_move.replace(false) {
window.start_window_move();
}
}),
)
}
}
fn down(at: Point<Pixels>) -> PlatformInput {
PlatformInput::MouseDown(MouseDownEvent {
button: MouseButton::Left,
position: at,
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
})
}
fn up(at: Point<Pixels>) -> PlatformInput {
PlatformInput::MouseUp(MouseUpEvent {
button: MouseButton::Left,
position: at,
modifiers: Modifiers::none(),
click_count: 1,
})
}
fn moved(at: Point<Pixels>, held: bool) -> PlatformInput {
PlatformInput::MouseMove(MouseMoveEvent {
position: at,
pressed_button: held.then_some(MouseButton::Left),
modifiers: Modifiers::none(),
})
}
const ON_ROW: Point<Pixels> = Point {
x: px(300.),
y: px(20.),
};
fn drifted(at: Point<Pixels>) -> Point<Pixels> {
point(at.x + px(12.), at.y + px(3.))
}
fn press_repaint_move(vcx: &mut VisualTestContext, at: Point<Pixels>) {
vcx.update(|window, cx| {
window.dispatch_event(moved(at, false), cx);
window.dispatch_event(down(at), cx);
});
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
vcx.update(|window, cx| {
window.dispatch_event(moved(drifted(at), true), cx);
});
vcx.run_until_parked();
}
#[gpui::test]
#[should_panic(expected = "not implemented")]
fn the_arm_survives_a_repaint_between_press_and_move(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Host);
let mut vcx = VisualTestContext::from_window(window.into(), cx);
press_repaint_move(&mut vcx, ON_ROW);
}
#[gpui::test]
fn a_per_frame_cell_loses_the_arm_to_the_same_repaint(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| PerFrameCellHost);
let mut vcx = VisualTestContext::from_window(window.into(), cx);
press_repaint_move(&mut vcx, ON_ROW);
}
#[gpui::test]
fn a_press_alone_does_not_move_the_window(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Host);
let mut vcx = VisualTestContext::from_window(window.into(), cx);
vcx.update(|window, cx| {
window.dispatch_event(moved(ON_ROW, false), cx);
window.dispatch_event(down(ON_ROW), cx);
window.dispatch_event(up(ON_ROW), cx);
});
vcx.run_until_parked();
}
#[gpui::test]
fn a_release_disarms_so_a_later_hover_does_not_drag(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Host);
let mut vcx = VisualTestContext::from_window(window.into(), cx);
vcx.update(|window, cx| {
window.dispatch_event(moved(ON_ROW, false), cx);
window.dispatch_event(down(ON_ROW), cx);
window.dispatch_event(up(ON_ROW), cx);
});
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
vcx.update(|window, cx| {
window.dispatch_event(moved(drifted(ON_ROW), false), cx);
});
vcx.run_until_parked();
}
#[gpui::test]
fn a_press_on_a_resize_handle_does_not_move_the_window(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| HandleOverRow { occluded: true });
let mut vcx = VisualTestContext::from_window(window.into(), cx);
press_repaint_move(&mut vcx, ON_ROW);
}
#[gpui::test]
#[should_panic(expected = "not implemented")]
fn a_handle_without_a_blocking_hitbox_hands_the_press_to_the_row(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| HandleOverRow { occluded: false });
let mut vcx = VisualTestContext::from_window(window.into(), cx);
press_repaint_move(&mut vcx, ON_ROW);
}
#[gpui::test]
fn two_rows_on_screen_keep_separate_arms(cx: &mut TestAppContext) {
struct TwoRows;
impl Render for TwoRows {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(super::title_bar_drag(
div().id("row-a").w_full().h(px(40.)),
"row-a",
window,
cx,
))
.child(super::title_bar_drag(
div().id("row-b").w_full().h(px(40.)),
"row-b",
window,
cx,
))
}
}
let window = cx.add_window(|_, _| TwoRows);
let mut vcx = VisualTestContext::from_window(window.into(), cx);
vcx.update(|window, cx| {
window.dispatch_event(moved(point(px(300.), px(20.)), false), cx);
window.dispatch_event(down(point(px(300.), px(20.))), cx);
});
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
vcx.update(|window, cx| {
window.dispatch_event(moved(point(px(312.), px(60.)), false), cx);
});
vcx.run_until_parked();
}
}
#[cfg(test)]
mod tests {
use super::{
CloseReason, DOCUMENT_MIN_W, Dir, Pane, Rename, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, Tab,
TabAgentSession, clear_window_override_values, close_prompt, document_column_px,
join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for,
parse_ssh_connect_input, parse_ssh_option_words, rename_outcome, side_panel_max,
split_shell_args, step_in_order, strip_band, wd_path_saveable,
};
use gpui::{Edges, point, px, size};
#[test]
fn a_rename_box_left_alone_is_not_a_rename() {
// The box opens holding the label already on screen, and `Blur`
// commits — so this is what happens when the user opens it, thinks
// better of it, and clicks away.
assert_eq!(
rename_outcome("api", "api"),
Rename::Unchanged,
"an untouched box asked for nothing"
);
assert_eq!(
rename_outcome(" api ", "api"),
Rename::Unchanged,
"whitespace either side is not an edit"
);
}
#[test]
fn an_emptied_rename_box_gives_the_tab_back_to_its_pane() {
// The only way to remove a name once set, so it has to survive the
// comparison above.
assert_eq!(rename_outcome("", "api"), Rename::Cleared);
assert_eq!(rename_outcome(" ", "api"), Rename::Cleared);
}
#[test]
fn a_typed_rename_box_names_the_tab() {
assert_eq!(
rename_outcome("billing", "api"),
Rename::Named("billing".into())
);
assert_eq!(
rename_outcome(" billing ", "api"),
Rename::Named("billing".into()),
"the name is stored trimmed"
);
}
const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH;
const PANEL_MIN: f32 = crate::ui::right_panel::MIN_WIDTH;
/// #738: which pane a directional move came from is remembered against the
/// pane it landed on, not against the direction alone.
///
/// One slot per direction is enough for a single move and back, but the
/// second step of a walk overwrites the first: left off `3` onto `2` and
/// left again onto `1` would leave only `1 -> 2`, and the second move back
/// right — the one out of `2` — would be handed no origin and fall to the
/// geometry that sent the user to the wrong pane in the first place.
#[test]
fn each_pane_remembers_the_move_that_landed_on_it() {
fn id(n: u64) -> gpui::EntityId {
gpui::EntityId::from(n)
}
let mut tab = Tab::new(Pane::Empty);
let live = [id(1), id(2), id(3)];
tab.remember_focus_origin(id(3), id(2), Dir::Left, &live);
tab.remember_focus_origin(id(2), id(1), Dir::Left, &live);
// Walking back retraces both steps rather than only the last one.
assert_eq!(tab.focus_origin(id(1), Dir::Right), Some(id(2)));
assert_eq!(tab.focus_origin(id(2), Dir::Right), Some(id(3)));
// Nothing is claimed about a direction no move went in, or about a pane
// no move has landed on.
assert_eq!(tab.focus_origin(id(2), Dir::Left), None);
assert_eq!(tab.focus_origin(id(3), Dir::Right), None);
// A pane the tab no longer holds takes its entries with it, on either
// side: with `3` closed, `2` no longer remembers having come from it,
// and the move back out of `2` is left to geometry.
tab.remember_focus_origin(id(1), id(2), Dir::Right, &[id(1), id(2)]);
assert_eq!(tab.focus_origin(id(2), Dir::Right), None);
assert_eq!(tab.focus_origin(id(2), Dir::Left), Some(id(1)));
}
/// #679: the band now starts at the frame padding rather than at the
/// window's corner, and off Linux CSD there is no padding to start at —
/// `window_paddings` answers `Edges::all(0)` under server-side decorations,
/// which is what macOS, Windows and a bare X session all report.
#[test]
fn an_undecorated_frame_leaves_the_strips_drop_band_where_it_was() {
let band = strip_band(size(px(1200.), px(800.)), Edges::all(px(0.)));
assert_eq!(band.origin, point(px(0.), px(0.)));
assert_eq!(band.size, size(px(1200.), px(TITLE_BAR_HEIGHT)));
}
/// The viewport measures the whole surface, shadow included, so the band
/// has to lose one padding at each end — not one twice over, and not none.
/// Getting it wrong puts the outermost chips outside the band drawn to hold
/// them, and a drop on them reads as a drop on nothing.
#[test]
fn a_client_side_frame_pulls_the_drop_band_in_by_the_shadow_on_both_sides() {
let viewport = size(px(1200.), px(800.));
let pad = Edges::all(px(12.));
let band = strip_band(viewport, pad);
assert_eq!(band.origin, point(pad.left, pad.top));
assert_eq!(
band.origin.x + band.size.width,
viewport.width - pad.right,
"the band must reach the far edge of the frame, not of the surface"
);
assert_eq!(band.size.height, px(TITLE_BAR_HEIGHT));
}
/// Fullscreen takes the window buttons and nothing else: the strip keeps
/// its row, and the room reserved for the buttons at its end comes back.
/// macOS never had any to take.
#[test]
fn fullscreen_drops_the_window_buttons_but_not_their_row() {
assert!(!super::window_controls_drawn(true));
assert_eq!(super::window_controls_w(true), 0.);
assert_eq!(
super::window_controls_drawn(false),
!cfg!(target_os = "macos")
);
assert_eq!(super::window_controls_w(false), super::WINDOW_CONTROLS_W);
assert_eq!(
crate::ui::tab_strip::trailing_chrome_w(true),
crate::ui::tab_strip::trailing_chrome_tiles_w()
);
}
/// A surface narrower than its own shadow is only reachable mid-resize, but
/// a negative width would make `Bounds::contains` answer for a rectangle
/// that is inside out.
#[test]
fn a_drop_band_narrower_than_its_frame_collapses_instead_of_inverting() {
let band = strip_band(size(px(10.), px(800.)), Edges::all(px(12.)));
assert_eq!(band.size.width, px(0.));
}
/// Two panels that each cap themselves at half the window leave the
/// terminal nothing when both are open, so the cap is what is left after
/// the terminal's floor and the *other* panel's floor — or half the window,
/// whichever binds harder.
#[test]
fn the_terminal_is_spoken_for_before_either_panel_is() {
// Mid width: the reservation binds, and a panel dragged to it with the
// other at its floor leaves the terminal exactly its floor. Two halves
// left it nothing.
let mid = 900.;
let sidebar = side_panel_max(mid, SIDEBAR_MIN, PANEL_MIN);
assert_eq!(sidebar, mid - TERMINAL_MIN_W - PANEL_MIN);
assert_eq!(mid - sidebar - PANEL_MIN, TERMINAL_MIN_W);
// With nothing on the other side, half the window already leaves the
// terminal the other half, so the older cap is the one that binds and
// a lone panel behaves exactly as it always did.
assert_eq!(side_panel_max(mid, SIDEBAR_MIN, 0.), mid / 2.);
}
/// A docked document is a third column in the same budget, so it has to be
/// reserved by the two panels the way they already reserve each other —
/// otherwise a panel dragged to its old limit takes the width out of the
/// document, which then has nowhere to take it from but the terminal.
#[test]
fn a_docked_document_is_reserved_by_the_panels_too() {
let wide = 1440.;
let max = side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN + DOCUMENT_MIN_W);
assert_eq!(
wide - SIDEBAR_MIN - DOCUMENT_MIN_W - max,
TERMINAL_MIN_W,
"a panel at its cap, with both other columns at their floors, leaves the terminal exactly its floor"
);
assert!(
max < side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN),
"the reservation only ever takes width away"
);
}
/// The floors are not what the panels are actually drawn at. Both are
/// draggable and both persist, so the budget has to be fed the live widths
/// or a widened sidebar is width the terminal silently loses.
#[test]
fn widened_panels_still_leave_the_terminal_its_floor() {
let viewport = 1440.;
// Both dragged well past their floors, and the document asked for the
// widest named share there is.
let body = viewport - 400. - 320.;
let document = document_column_px(body, 2. / 3.).expect("720 points seats both");
assert!(
body - document >= TERMINAL_MIN_W,
"terminal got {}",
body - document
);
// Squeezed further, the document is the one that gives up first — and
// then stops existing rather than dropping under its own floor.
let squeezed = viewport - 600. - 400.;
assert_eq!(document_column_px(squeezed, 0.5), None);
}
/// The reservation must only ever take width away from a panel. On a wide
/// window it works out *larger* than the half-window cap that was already
/// there, and letting it win would widen the ceiling instead.
#[test]
fn a_wide_window_still_stops_a_panel_at_half_of_it() {
let wide = 1440.;
assert!(
wide - TERMINAL_MIN_W - PANEL_MIN > wide / 2.,
"otherwise this test is not testing the case it names"
);
assert_eq!(side_panel_max(wide, SIDEBAR_MIN, PANEL_MIN), wide / 2.);
assert_eq!(side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN), wide / 2.);
}
/// Below the width where everything fits, the floor wins over the
/// reservation: a cap under a panel's own minimum would be a panel drawn
/// narrower than it can be read at, and the terminal — which can reflow —
/// takes the shortfall instead.
#[test]
fn a_window_too_narrow_for_all_three_falls_back_to_the_floors() {
let narrow = 720.;
assert_eq!(side_panel_max(narrow, SIDEBAR_MIN, PANEL_MIN), SIDEBAR_MIN);
assert_eq!(side_panel_max(narrow, PANEL_MIN, SIDEBAR_MIN), PANEL_MIN);
// Both panels pinned to their floors leaves the terminal the rest —
// less than its floor, but more than the 260-odd points two saved
// widths used to leave it.
assert!(narrow - SIDEBAR_MIN - PANEL_MIN > 300.);
}
#[test]
fn a_start_in_path_saves_only_when_it_names_a_real_directory() {
// Empty is "unset", not a broken path.
assert!(wd_path_saveable(""));
assert!(wd_path_saveable(" "));
let real = std::env::temp_dir();
let real = real.to_str().expect("temp dir is utf-8 here");
assert!(wd_path_saveable(real), "{real} exists");
assert!(
wd_path_saveable(&format!(" {real} ")),
"the commit trims, so the check trims too"
);
assert!(!wd_path_saveable("/definitely/not/a/real/dir"));
// A file is not a directory the shell can start in.
let file = std::env::temp_dir().join("tty7-wd-saveable-probe");
std::fs::write(&file, b"x").expect("write probe file");
assert!(!wd_path_saveable(file.to_str().expect("utf-8")));
let _ = std::fs::remove_file(&file);
}
#[test]
fn the_close_question_names_what_it_is_about_to_end() {
use crate::terminal::view::PaneBusy;
crate::ui::i18n::set_locale("en");
let build = CloseReason::Busy(PaneBusy::Command("cargo build".into()));
// The command is in the body, not a generic "are you sure".
let (title, body) = close_prompt(true, &build);
assert!(body.contains("cargo build"), "{body}");
assert!(!body.contains("{what}"), "the placeholder leaked: {body}");
assert!(title.contains("tab"), "{title}");
// Same reason, but the pane has siblings: the tab survives, so the
// question must not claim otherwise.
let (pane_title, _) = close_prompt(false, &build);
assert!(pane_title.contains("pane"), "{pane_title}");
assert_ne!(title, pane_title);
let agent = CloseReason::Busy(PaneBusy::Agent("Claude Code"));
let (_, body) = close_prompt(true, &agent);
assert!(body.contains("Claude Code"), "{body}");
assert!(!body.contains("{agent}"), "the placeholder leaked: {body}");
// A live SSH connection keeps its own wording rather than being folded
// into the busy copy.
let (ssh_title, ssh_body) = close_prompt(true, &CloseReason::LiveSsh);
assert_ne!(ssh_title, title);
assert!(!ssh_body.is_empty());
}
#[test]
fn a_layout_that_came_back_short_says_how_short() {
crate::ui::i18n::set_locale("en");
for n in [1usize, 2, 9] {
let text =
crate::ui::i18n::t_plural(crate::ui::i18n::L10nKey::AppTabsNotRestored, n, &[]);
assert!(text.contains(&n.to_string()), "{n}: {text}");
assert!(!text.contains("{count}"), "the placeholder leaked: {text}");
}
// Singular is not "1 tabs".
let one = crate::ui::i18n::t_plural(crate::ui::i18n::L10nKey::AppTabsNotRestored, 1, &[]);
assert!(one.contains("1 tab "), "{one}");
}
#[test]
fn non_windows_reset_preserves_the_synced_windows_backdrop() {
let mut config = crate::core::config::Config::default();
config.window_opacity = Some(0.8);
config.window_blur = Some(true);
config.window_backdrop = crate::core::config::WindowBackdrop::Mica;
clear_window_override_values(&mut config, false);
assert_eq!(config.window_opacity, None);
assert_eq!(config.window_blur, None);
assert_eq!(
config.window_backdrop,
crate::core::config::WindowBackdrop::Mica,
"an inert synchronized backdrop is not a local override to reset"
);
}
#[test]
fn windows_reset_clears_the_local_backdrop_override() {
let mut config = crate::core::config::Config::default();
config.window_backdrop = crate::core::config::WindowBackdrop::Acrylic;
clear_window_override_values(&mut config, true);
assert_eq!(
config.window_backdrop,
crate::core::config::WindowBackdrop::Auto
);
}
#[test]
fn mru_puts_the_active_tab_first_and_the_last_one_used_behind_it() {
// Tab 2 is active; 0 was used most recently before it, then 1.
assert_eq!(mru_order(&[7, 4, 9], 2), vec![2, 0, 1]);
}
#[test]
fn mru_leads_with_the_active_tab_even_before_its_stamp_lands() {
assert_eq!(mru_order(&[5, 0, 3], 1), vec![1, 0, 2]);
}
#[test]
fn mru_trails_never_activated_tabs_in_strip_order() {
assert_eq!(mru_order(&[0, 6, 0, 0], 1), vec![1, 0, 2, 3]);
}
#[test]
fn mru_of_a_windowless_workspace_is_empty() {
assert!(mru_order(&[], 0).is_empty());
}
#[test]
fn stepping_through_tabs_follows_the_strip_and_wraps() {
let order = [0, 1, 2];
assert_eq!(step_in_order(&order, 0, true), Some(1));
assert_eq!(step_in_order(&order, 2, true), Some(0));
assert_eq!(step_in_order(&order, 0, false), Some(2));
assert_eq!(step_in_order(&order, 1, false), Some(0));
// Pressing it again keeps going — this is not the MRU switcher, which
// bounces between the last two tabs (#867).
let mut at = 0;
let seen: Vec<usize> = (0..4)
.map(|_| {
at = step_in_order(&order, at, true).unwrap();
at
})
.collect();
assert_eq!(seen, vec![1, 2, 0, 1]);
}
#[test]
fn stepping_follows_the_grouped_sidebar_order_not_the_index() {
// Sidebar groups reorder the rows: index 2 is shown second.
let order = [0, 2, 1, 3];
assert_eq!(step_in_order(&order, 0, true), Some(2));
assert_eq!(step_in_order(&order, 2, true), Some(1));
assert_eq!(step_in_order(&order, 3, true), Some(0));
assert_eq!(step_in_order(&order, 0, false), Some(3));
}
#[test]
fn stepping_has_nowhere_to_go_with_one_tab() {
assert_eq!(step_in_order(&[], 0, true), None);
assert_eq!(step_in_order(&[0], 0, true), None);
assert_eq!(step_in_order(&[0], 0, false), None);
}
#[test]
fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() {
let ours = crate::core::session::WorkspaceId::new();
let theirs = crate::core::session::WorkspaceId::new();
let alive: std::collections::HashMap<u64, Option<String>> = [
(1, Some(ours.to_string())),
(2, Some(theirs.to_string())),
(3, None),
(5, Some("tty7-cli".to_string())),
]
.into_iter()
.collect();
assert!(
pane_free_for(Some(&alive), 1, ours),
"our own pane attaches"
);
assert!(
!pane_free_for(Some(&alive), 2, ours),
"another workspace's pane must spawn fresh instead"
);
assert!(
pane_free_for(Some(&alive), 3, ours),
"an unowned pane is legacy"
);
assert!(
pane_free_for(Some(&alive), 5, ours),
"an owner that names no workspace is not a rival's claim: older CLIs \
wrote their own name there, and respawning strands the live pane"
);
assert!(
pane_free_for(None, 4, ours),
"a failed List says nothing about pane 4; the attach itself must decide, \
because respawning on a transient RPC error destroys a live session"
);
}
#[test]
fn a_dead_pane_keeps_its_id_so_its_screen_can_be_asked_for() {
let ours = crate::core::session::WorkspaceId::new();
let theirs = crate::core::session::WorkspaceId::new();
let alive: std::collections::HashMap<u64, Option<String>> =
[(1, Some(ours.to_string())), (2, Some(theirs.to_string()))]
.into_iter()
.collect();
// The restart case: every pane the window held is missing from the new
// daemon's listing. Their ids are the only handle on the screens it
// still has stored, so being dead must not erase them — this is what
// made a restarted server come back to a row of blank shells.
assert!(
pane_free_for(Some(&alive), 4, ours),
"a dead pane's id has to survive; the restore is keyed on it"
);
// What being free does not mean: helping yourself to a pane that is
// alive and belongs to another workspace, whose screen is not this
// window's to show either.
assert!(
!pane_free_for(Some(&alive), 2, ours),
"another workspace's pane is not ours to restore from"
);
assert!(pane_free_for(Some(&alive), 1, ours), "our own pane is ours");
}
#[test]
fn a_native_ssh_leaf_in_a_remote_window_is_not_looked_up_in_the_remote_daemon() {
assert!(!leaf_shares_the_window_daemon(true, true));
assert!(leaf_shares_the_window_daemon(true, false));
assert!(leaf_shares_the_window_daemon(false, true));
assert!(leaf_shares_the_window_daemon(false, false));
}
#[test]
fn a_fork_needs_a_command_an_id_and_a_local_pane() {
let session = |fork_label, session_id: Option<&str>, remote| TabAgentSession {
fork_label,
session_id: session_id.map(str::to_string),
remote,
};
assert!(session(Some("Fork Session"), Some("abc"), false).forkable());
assert!(
!session(None, Some("abc"), false).forkable(),
"an agent with no fork command is never forkable"
);
assert!(
!session(Some("Fork Session"), None, false).forkable(),
"no session id yet — the hooks haven't reported one"
);
assert!(
!session(Some("Fork Session"), Some("abc"), true).forkable(),
"a remote pane would fork the wrong machine's session"
);
}
#[gpui::test]
fn a_connecting_pane_saves_the_agent_it_is_rebuilding(cx: &mut gpui::TestAppContext) {
use crate::core::cli_agent::CLIAgent;
use crate::core::session::SessionPane;
use crate::ui::pane::{Pane, PaneSlot};
use crate::ui::pending_pane::{PendingPane, PendingSpawn};
use gpui::AppContext as _;
cx.update(|cx| {
let pending = cx.new(|cx| {
PendingPane::new(
"build-box",
PendingSpawn {
workspace: None,
working_directory: Some(std::path::PathBuf::from("/work")),
restore_pane: Some(7),
shell: None,
agent: Some(CLIAgent::Claude),
agent_session_id: Some("sid-abc".to_string()),
agent_launch_argv: Some(vec!["claude".to_string()]),
owner: None,
font_size: 14.0,
},
cx,
)
});
let saved = super::pane_to_session(&Pane::leaf(PaneSlot::Connecting(pending)), cx);
let SessionPane::Leaf {
pane_id,
agent,
agent_session_id,
agent_launch_argv,
..
} = saved
else {
panic!("a leaf saves as a leaf");
};
assert_eq!(pane_id, Some(7), "the id it is re-attaching to");
assert_eq!(agent, Some(CLIAgent::Claude));
assert_eq!(agent_session_id.as_deref(), Some("sid-abc"));
assert_eq!(agent_launch_argv, Some(vec!["claude".to_string()]));
});
}
#[test]
fn parses_ssh_option_words_with_quotes() {
assert_eq!(
parse_ssh_option_words("-p 2222 -J 'jump host' -o \"User=dev\"").unwrap(),
vec!["-p", "2222", "-J", "jump host", "-o", "User=dev"]
);
}
#[test]
fn rejects_unclosed_ssh_option_quote() {
assert!(parse_ssh_option_words("-J 'jump").is_err());
}
#[test]
fn parses_typed_connect_into_native_profile() {
let p = parse_ssh_connect_input("ssh deploy@10.0.0.5:2222").unwrap();
assert_eq!(p.profile.host, "10.0.0.5");
assert_eq!(p.profile.user, "deploy");
assert_eq!(p.profile.port, 2222);
assert!(p.proxy_jump.is_none());
}
#[test]
fn parses_typed_connect_flags_and_jump() {
let p =
parse_ssh_connect_input("ssh -p 2222 -l dev -i ~/.ssh/id_ed25519 -J 'jump host' host")
.unwrap();
assert_eq!(p.profile.host, "host");
assert_eq!(p.profile.user, "dev");
assert_eq!(p.profile.port, 2222);
assert_eq!(
p.profile.identity_files,
vec!["~/.ssh/id_ed25519".to_string()]
);
assert_eq!(p.proxy_jump.as_deref(), Some("jump host"));
let p = parse_ssh_connect_input("host -p2222 -o User=deploy -o Port=2200").unwrap();
assert_eq!(p.profile.user, "deploy");
assert_eq!(p.profile.port, 2200);
}
#[test]
fn explicit_flags_override_target_userhost() {
let p = parse_ssh_connect_input("ssh me@host:22 -l other -p 2200").unwrap();
assert_eq!(p.profile.user, "other");
assert_eq!(p.profile.port, 2200);
}
#[test]
fn rejects_bad_typed_connect_lines() {
assert!(parse_ssh_connect_input("ssh -p 2222").is_err());
assert!(parse_ssh_connect_input("ssh dev uptime").is_err());
assert!(parse_ssh_connect_input("ssh -- dev").is_err());
assert!(parse_ssh_connect_input("ssh 'host").is_err());
assert!(parse_ssh_connect_input("ssh host -p 0").is_err());
}
fn argv(words: &[&str]) -> Vec<String> {
words.iter().map(|w| (*w).to_string()).collect()
}
/// The invariant #551 is fixed with: what `build_shell_inputs` writes into
/// the Arguments field must parse back to exactly the argv it came from, so
/// that opening Settings and clicking away cannot rewrite `config.json`.
#[test]
fn shell_arguments_round_trip_between_field_text_and_argv() {
for case in [
&["-l"][..],
&["-c", "echo hi"],
// A space-join cannot even spell these three.
&[""],
&["don't"],
&["--say", "it's \"quoted\""],
// Backslashes are how a path is spelled on Windows, and the field
// has to be able to carry one either bare or inside quotes.
&[r"--dir", r"C:\Users\me"],
&[r"--dir", r"C:\Program Files\tty7"],
&["--tag", "#1"],
] {
let argv = argv(case);
let field_text = join_shell_args(&argv);
assert_eq!(
split_shell_args(&field_text).expect("tty7's own refill must parse"),
argv,
"round trip through {field_text:?}"
);
}
// Nothing that does not need quoting acquires any: the everyday field
// still reads exactly like the user typed it.
assert_eq!(
join_shell_args(&argv(&["-l", "--color=auto", r"C:\Users\me", "#1"])),
r"-l --color=auto C:\Users\me #1"
);
assert_eq!(join_shell_args(&[]), "");
}
/// What the user types has to reach argv meaning what it says: quoted text
/// is one argument, and nothing outside quotes is an escape or a comment.
#[test]
fn shell_arguments_split_the_way_the_field_promises() {
assert_eq!(
split_shell_args("--login -c \"echo hi\"").expect("balanced quotes parse"),
argv(&["--login", "-c", "echo hi"])
);
assert_eq!(
split_shell_args("-c 'echo hi'").expect("single quotes parse"),
argv(&["-c", "echo hi"])
);
// A backslash outside quotes is a character, not an escape: there is no
// shell between this field and argv, and POSIX rules would quietly turn
// a Windows path into `C:Usersme`.
assert_eq!(
split_shell_args(r"--dir C:\Users\me").expect("a bare path parses"),
argv(&["--dir", r"C:\Users\me"])
);
// `#` starts no comment either — swallowing the rest of the line is the
// silent rewrite this change exists to stop.
assert_eq!(
split_shell_args("--tag #1 --verbose").expect("a hash parses"),
argv(&["--tag", "#1", "--verbose"])
);
// Inside double quotes, and only there, `\"` and `\\` stand for
// themselves — that is how `join_shell_args` spells an argument that
// carries both kinds of quote.
assert_eq!(
split_shell_args("\"it's \\\"quoted\\\"\"").expect("escapes parse"),
argv(&["it's \"quoted\""])
);
assert_eq!(
split_shell_args("'' -l").expect("an empty argument parses"),
argv(&["", "-l"])
);
}
/// The other half of the #551 contract: a value that cannot become argv is
/// refused, not shredded into fragments that happen to contain quotes.
#[test]
fn unbalanced_quotes_are_refused_not_shredded() {
assert!(split_shell_args("-c \"echo hi").is_err());
assert!(split_shell_args("--name 'tty7").is_err());
assert!(split_shell_args("\"a\\\"").is_err());
}
}
#[cfg(test)]
pub(crate) mod test_window {
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
pub(crate) fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
window
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.background_executor.run_until_parked();
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
let vcx = VisualTestContext::from_window(window.into(), cx);
(app, vcx)
}
/// A window carrying `n` quiet tabs, active on the first.
pub(crate) fn harness_with_tabs(
cx: &mut TestAppContext,
n: usize,
) -> (
Entity<Tty7App>,
VisualTestContext,
Vec<crate::daemon::transport::Stream>,
) {
use crate::terminal::view::quiet_test_pane;
use crate::ui::pane::{Pane, PaneSlot};
let (app, mut vcx) = harness(cx);
vcx.update(|_, cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.cursor_blink = false;
cx.set_global(cfg);
});
let streams = app.update_in(&mut vcx, |app, window, cx| {
let mut streams = Vec::new();
for i in 0..n {
let (view, stream) = quiet_test_pane(i as u64 + 1, window, cx);
app.tabs
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(view))));
streams.push(stream);
}
app.active = 0;
cx.notify();
streams
});
vcx.background_executor.run_until_parked();
(app, vcx, streams)
}
pub(crate) fn harness_with_pane(
cx: &mut TestAppContext,
) -> (
Entity<Tty7App>,
VisualTestContext,
crate::daemon::transport::Stream,
) {
use crate::terminal::view::quiet_test_pane;
use crate::ui::pane::{Pane, PaneSlot};
let (app, mut vcx) = harness(cx);
vcx.update(|_, cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.cursor_blink = false;
cx.set_global(cfg);
});
let stream = app.update_in(&mut vcx, |app, window, cx| {
let (view, stream) = quiet_test_pane(1, window, cx);
app.tabs
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(view))));
app.active = 0;
cx.notify();
stream
});
vcx.background_executor.run_until_parked();
(app, vcx, stream)
}
/// Wait until the window has actually stopped drawing — which is not the
/// same as having reached the state a test was waiting for.
///
/// Called both after a settle and at the top of `draws_while_idle`: a test
/// that reaches its state, asserts a few things about it and only then
/// measures has given the setup more time to land, but not necessarily
/// enough, and the measurement is the place that cannot afford to be
/// wrong.
///
/// Both of a `render_idle` test's clocks have to be pumped here, and they
/// are pumped differently.
///
/// The pane runs its own git pipeline, separate from whatever panel is on
/// screen, and it hangs off a 300ms timer on the *virtual* clock — so it
/// never starts at all unless a test advances that clock. It used to be
/// `draws_while_idle`'s own `advance_clock` that started it, which put the
/// pane's first real `git` run, and the repaint it lands with, inside the
/// window being counted. Whether that repaint arrived before or after the
/// count then came down to how fast git ran, which is why these tests were
/// green here and red on a loaded CI runner (issue #523).
///
/// What that repaint sets off in turn is timed on the *real* clock: the
/// landing opens a `GIT_WATCH_DEBOUNCE` burst, and closing the burst costs
/// another frame 250ms later. So the sleep below is load-bearing too, and
/// a round that drew nothing is not on its own enough to stop on — a burst
/// still open is a frame already owed.
pub(crate) fn quiesce(vcx: &mut VisualTestContext, cwd: Option<&std::path::Path>) {
use crate::terminal::git_data::ScmData;
use crate::terminal::git_status::GitStatusCache;
use crate::ui::app::render_probe;
use crate::ui::host_ops::HostId;
/// How long quiet has to hold before it counts as quiet.
///
/// Real time, and the only defence against the third clock in play:
/// the kernel's. The file tree keeps a real `inotify`/`FSEvents`
/// watch, and the writes a test makes while setting up its repository
/// are still being delivered long after every future the test can wait
/// on has resolved. They arrive on the channel, sit in a 200ms debounce
/// on the virtual clock, and are released by the next `advance_clock`
/// — which, without this, was the measurement's own. Any delivery
/// restarts the hold, so the wait is as long as the runner needs and
/// no longer.
const QUIET_HOLD: std::time::Duration = std::time::Duration::from_millis(400);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut quiet_since: Option<std::time::Instant> = None;
loop {
render_probe::arm(u64::MAX);
vcx.executor()
.advance_clock(std::time::Duration::from_millis(300));
vcx.background_executor.run_until_parked();
let quiet = render_probe::draws() == 0
&& vcx.update(|_, cx| {
let owed = cx
.try_global::<ScmData>()
.is_some_and(ScmData::is_debouncing);
let answered = cwd.is_none_or(|cwd| {
cx.try_global::<GitStatusCache>()
.and_then(|cache| cache.known_repo_for(HostId::LOCAL, cwd))
.is_some()
});
!owed && answered
});
match quiet {
false => quiet_since = None,
true => {
let since = *quiet_since.get_or_insert_with(std::time::Instant::now);
if since.elapsed() >= QUIET_HOLD {
return;
}
}
}
assert!(
std::time::Instant::now() < deadline,
"the window never stopped drawing"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
}
#[cfg(test)]
mod cursor_blink_gpui_tests {
use super::test_window::harness;
use crate::core::config::Config;
use crate::terminal::view::quiet_test_pane;
use crate::ui::pane::{Pane, PaneSlot};
use gpui::TestAppContext;
#[gpui::test]
fn only_the_focused_pane_advances_its_blink_phase(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
vcx.update(|_, cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.cursor_blink = true;
cx.set_global(cfg);
});
let (left, right, _streams) = app.update_in(&mut vcx, |app, window, cx| {
let (left, left_stream) = quiet_test_pane(1, window, cx);
let (right, right_stream) = quiet_test_pane(2, window, cx);
app.tabs.push(super::Tab::new(Pane::split_node(
gpui::Axis::Horizontal,
0.5,
Pane::leaf(PaneSlot::Ready(left.clone())),
Pane::leaf(PaneSlot::Ready(right.clone())),
)));
app.active = 0;
let left_focus = left.read(cx).focus_handle.clone();
left_focus.focus(window, cx);
cx.notify();
(left, right, (left_stream, right_stream))
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |_, window, cx| {
assert!(left.read(cx).focus_handle.is_focused(window));
assert!(!right.read(cx).focus_handle.is_focused(window));
assert!(left.read(cx).cursor_visible);
assert!(right.read(cx).cursor_visible);
});
vcx.executor()
.advance_clock(std::time::Duration::from_millis(530));
vcx.background_executor.run_until_parked();
app.update(&mut vcx, |_, cx| {
assert!(!left.read(cx).cursor_visible, "the focused cursor blinks");
assert!(
right.read(cx).cursor_visible,
"the unfocused cursor must keep a steady phase"
);
});
}
}
#[cfg(test)]
mod ssh_rebuild_gpui_tests {
use super::test_window::harness_with_pane;
use crate::core::session::{
RemoteRef, RemoteTarget, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::ui::pane::{Pane, PaneSlot};
use gpui::TestAppContext;
use tty7_core::core::machine::{LayoutDelta, PaneNode, Tab as TreeTab};
#[gpui::test]
fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) {
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
));
let remote_id = remote.id;
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![remote],
active: None,
},
);
app.workspace = remote_id;
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
let existing = std::mem::replace(&mut app.tabs[0].pane, Pane::Empty);
app.tabs[0].pane = Pane::split_node(
gpui::Axis::Horizontal,
0.5,
existing,
Pane::leaf(PaneSlot::Ready(ssh_view)),
);
stream
});
let applied = app.update_in(&mut vcx, |app, window, cx| {
let tab = TreeTab {
id: app.tabs[0].tree_id.get(),
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
};
app.apply_layout_delta(
&LayoutDelta::TabRestructured { tab, pane: None },
window,
cx,
)
});
assert!(
applied,
"the delta must apply without falling back to a resync"
);
app.update_in(&mut vcx, |app, _, cx| {
let leaves = app.tabs[0].pane.leaves();
assert_eq!(leaves.len(), 2, "the ssh split must survive the rebuild");
assert!(
leaves.iter().any(|slot| match slot {
PaneSlot::Ready(view) => view.read(cx).ssh_spec().is_some(),
_ => false,
}),
"one leaf is still the native-SSH pane"
);
assert!(
leaves.iter().any(|slot| match slot {
PaneSlot::Ready(view) => {
let view = view.read(cx);
view.ssh_spec().is_none() && view.pane_id == 1
}
_ => false,
}),
"the remote pane's existing view is reused, not re-attached"
);
});
}
#[gpui::test]
fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) {
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
));
let remote_id = remote.id;
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![remote],
active: None,
},
);
app.workspace = remote_id;
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
app.tabs
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view))));
stream
});
let (desired, _active, held) = app.update_in(&mut vcx, |app, _, cx| {
crate::ui::tree_sync::desired_tabs(app, cx)
});
assert_eq!(
desired.len(),
1,
"only the remote-backed tab can be named in the machine's tree"
);
assert!(
held.is_empty(),
"the pure-SSH tab is permanently invisible, not held — holding it \
would freeze ordering and active-tab sync for the whole window"
);
}
}
#[cfg(test)]
mod keybinding_gpui_tests {
use super::test_window::harness;
use crate::core::config::Config;
use crate::ui::app::Tty7App;
use crate::ui::settings::SettingsSection;
use gpui::{Entity, TestAppContext, VisualTestContext};
fn begin_capture(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, action: &str) {
let action = action.to_string();
app.update_in(vcx, |app, window, cx| {
app.toggle_settings(window, cx);
app.select_settings_section(SettingsSection::Keybindings, cx);
app.start_recording_key(action, window, cx);
});
}
/// Waits for `action`'s entry in config to read `expected` — compared as the
/// JSON the file gets, since that is what the reader of `config.json` sees.
fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: serde_json::Value) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
vcx.background_executor.run_until_parked();
let got = vcx.update(|_, cx| {
serde_json::to_value(cx.global::<Config>().keybindings.get(action))
.expect("a binding serializes")
});
if got == expected {
return;
}
assert!(
std::time::Instant::now() < deadline,
"binding for {action} never became {expected:?} (last {got:?})"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
#[gpui::test]
fn recording_a_shortcut_writes_the_override_and_ends_capture(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
begin_capture(&app, &mut vcx, "NewTab");
vcx.simulate_keystrokes("secondary-shift-n");
// A list, because recording a shortcut on the Settings page *sets* it:
// the row showed one chord and now shows another. A bare string in
// config adds a chord beside the default (#868), which is not what
// the person at the row just did.
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-shift-n"]));
let recording = app.update_in(&mut vcx, |app, _, _| {
app.active_settings().map(|s| s.recording.is_some())
});
assert_eq!(
recording,
Some(false),
"capture should end after committing"
);
}
#[gpui::test]
fn recording_a_two_chord_sequence_writes_the_full_spec(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
begin_capture(&app, &mut vcx, "CloseActiveTab");
vcx.simulate_keystrokes("secondary-b");
vcx.simulate_keystrokes("x");
wait_for_binding(
&mut vcx,
"CloseActiveTab",
serde_json::json!(["secondary-b x"]),
);
}
#[gpui::test]
fn recording_a_chord_another_action_also_has_takes_only_that_chord(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
vcx.update(|_, cx| {
cx.global_mut::<Config>().keybindings = serde_json::from_value(serde_json::json!({
"NextTab": ["ctrl-tab", "secondary-alt-n"],
}))
.expect("the binding loads");
crate::ui::keymap::rebind(cx);
});
begin_capture(&app, &mut vcx, "NewTab");
vcx.simulate_keystrokes("secondary-alt-n");
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-alt-n"]));
// Emptying the other action was right when an action had one chord.
// With two, it would take Ctrl+Tab away as well, for a keystroke that
// was never on it.
wait_for_binding(&mut vcx, "NextTab", serde_json::json!(["ctrl-tab"]));
}
#[gpui::test]
fn recording_an_extra_default_chord_displaces_its_owner(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
begin_capture(&app, &mut vcx, "NewTab");
vcx.simulate_keystrokes("alt-enter");
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["alt-enter"]));
wait_for_binding(&mut vcx, "InsertNewline", serde_json::json!([]));
let note = app.update_in(&mut vcx, |app, _, _| {
app.active_settings().and_then(|s| s.rebinding_note.clone())
});
assert!(
note.as_deref()
.is_some_and(|n| n.contains("Insert Newline")),
"the takeover note must name the action that lost the chord (got {note:?})"
);
}
/// #901, the half that happens in the UI: Alt+1…9 belongs to vim, and the
/// only gesture in the app that looks like "take this shortcut away" used
/// to *reset* the row instead — a no-op on a row nobody had overridden,
/// so the default appeared to restore itself however many times it was
/// pressed.
#[gpui::test]
fn backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default(
cx: &mut TestAppContext,
) {
let (app, mut vcx) = harness(cx);
let shipped = vcx
.update(|_, cx| crate::ui::keymap::effective_key("ActivateTab1", cx))
.expect("Go to Tab 1 ships with a chord");
begin_capture(&app, &mut vcx, "ActivateTab1");
vcx.simulate_keystrokes("backspace");
wait_for_binding(&mut vcx, "ActivateTab1", serde_json::json!([]));
vcx.update(|_, cx| {
assert_eq!(
crate::ui::keymap::effective_key("ActivateTab1", cx),
None,
"the row has no chord left to show"
);
let typed = [gpui::Keystroke::parse(&shipped).expect("the chord parses")];
let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")];
assert!(
cx.key_bindings()
.borrow()
.bindings_for_input(&typed, &context)
.0
.is_empty(),
"{shipped} must reach the terminal now, not the tab switcher"
);
});
// Reversible, and by the button that is already on the row: an
// overridden action — unbound counts — shows **Reset**.
app.update_in(&mut vcx, |app, _, cx| {
app.reset_keybinding("ActivateTab1".to_string(), cx)
});
vcx.update(|_, cx| {
assert_eq!(
crate::ui::keymap::effective_key("ActivateTab1", cx).as_deref(),
Some(shipped.as_str()),
"Reset is the way back to the shipped chord"
);
});
}
#[gpui::test]
fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
app.update_in(&mut vcx, |app, window, cx| {
app.toggle_settings(window, cx);
app.select_settings_section(SettingsSection::Keybindings, cx);
app.start_recording_key("NewTab".to_string(), window, cx);
});
vcx.simulate_keystrokes("escape");
vcx.background_executor.run_until_parked();
let stored = vcx.update(|_, cx| cx.global::<Config>().keybindings.contains_key("NewTab"));
assert!(!stored, "Esc must not persist a binding");
let recording = app.update_in(&mut vcx, |app, _, _| {
app.active_settings().map(|s| s.recording.is_some())
});
assert_eq!(recording, Some(false));
}
}
#[cfg(test)]
mod ui_font_gpui_tests {
use super::test_window::harness;
use crate::core::config::Config;
use crate::ui::settings::ui_font_default_label;
use gpui::TestAppContext;
use gpui_component::Theme;
/// Picking a face and picking Default back are the same rule read in both
/// directions. Only one of them used to be spelled: the chrome took the
/// pick, but clearing it wrote `None` to `config.json` and left the window
/// in the old face until the next launch — a saved setting that looked
/// like it had applied instantly and had not.
#[gpui::test]
fn clearing_the_interface_font_puts_the_stock_face_back(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
let stock = vcx.update(|_, cx| Theme::global(cx).font_family.clone());
app.update_in(&mut vcx, |app, window, cx| {
app.commit_ui_font_family("Courier".to_string(), window, cx);
});
vcx.update(|_, cx| {
assert_eq!(
cx.global::<Config>().ui_font_family.as_deref(),
Some("Courier")
);
assert_eq!(Theme::global(cx).font_family.as_ref(), "Courier");
});
app.update_in(&mut vcx, |app, window, cx| {
app.commit_ui_font_family(ui_font_default_label().to_string(), window, cx);
});
vcx.update(|_, cx| {
assert_eq!(cx.global::<Config>().ui_font_family, None);
assert_eq!(
Theme::global(cx).font_family,
stock,
"Default has to hand the interface back to the system face"
);
});
}
}
#[cfg(test)]
mod restart_server_gpui_tests {
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use gpui::{AppContext, TestAppContext};
/// Clicking Restart Server made the whole app disappear.
///
/// The work that puts the window back together after the restart ends by
/// rebuilding every local window from the machine tree, and the first thing
/// that rebuild asks each window is which tabs it is showing — which it
/// reads back out of the window registry. Run from inside `update_in` on
/// this window's own entity, the first window it reaches for is the one the
/// closure already holds leased, and gpui answers a double lease by
/// panicking, which on the main thread is the process.
///
/// Driven through `settle_after_restart` with a restart that "succeeded",
/// because the crash is in the part that runs either way, not in the
/// restart itself.
#[gpui::test]
async fn settling_after_a_restart_does_not_lease_the_window_twice(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
crate::ui::windows::WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
// Registered the way an opened window registers itself: without this
// the rebuild finds no window to ask and never reaches for the entity,
// which is the whole thing under test.
let handle = window.into();
let weak = app.downgrade();
app.update(cx, |app, cx| {
crate::ui::windows::WindowRegistry::register(cx, app.workspace, handle, weak);
});
Tty7App::settle_after_restart(app.downgrade(), Ok(()), &mut cx.to_async()).await;
assert!(
app.update(cx, |app, _| app.startup_error.is_none()),
"a restart reported as successful must not leave an error banner"
);
}
}
#[cfg(test)]
mod shell_menu_gpui_tests {
use crate::core::config::Config;
use crate::core::session::{
RemoteRef, RemoteTarget, Session, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::ui::app::Tty7App;
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
crate::ui::windows::WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
let vcx = VisualTestContext::from_window(window.into(), cx);
(app, vcx)
}
fn pump_until(
app: &Entity<Tty7App>,
vcx: &mut VisualTestContext,
done: impl Fn(&Tty7App) -> bool,
) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
vcx.background_executor.run_until_parked();
if app.update(vcx, |app, _| done(app)) {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[gpui::test]
fn a_local_window_lists_this_computers_shells(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
assert!(
pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()),
"the local probe never landed"
);
app.update(&mut vcx, |app, _| {
assert!(app.shells_host.is_local());
assert!(
!app.shells.default_name.is_empty(),
"the menu has no default to tag"
);
});
}
#[gpui::test]
fn an_unreachable_remote_window_offers_no_local_shells(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
assert!(
pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()),
"the local probe never landed"
);
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
));
let remote_id = remote.id;
app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![remote],
active: None,
},
);
app.switch_workspace(Some(remote_id), window, cx);
});
assert!(
pump_until(&app, &mut vcx, |app| !app.shells_host.is_local()),
"the window never rebound to the remote machine"
);
app.update(&mut vcx, |app, _| {
assert!(
app.shells.shells.is_empty(),
"a remote window must not offer this computer's shells: {:?}",
app.shells.shells
);
});
}
}
#[cfg(test)]
mod rename_gpui_tests {
use gpui::TestAppContext;
use crate::ui::app::test_window::harness_with_tabs;
#[gpui::test]
fn a_rename_box_opens_with_the_caret_after_the_name(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |app, window, cx| app.start_rename(0, window, cx));
vcx.background_executor.run_until_parked();
app.update(&mut vcx, |app, cx| {
let input = app
.renaming
.as_ref()
.expect("the rename box is up")
.input
.clone();
let state = input.read(cx);
let value = state.value().to_string();
assert!(!value.is_empty(), "the box starts on the current name");
let end = value.len();
assert_eq!(
state.selected_range(),
end..end,
"typing has to continue {value:?}, not land in front of it"
);
});
}
#[gpui::test]
fn a_rename_rides_out_other_tabs_closing_and_the_strip_reordering(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
app.update_in(&mut vcx, |app, window, cx| {
app.start_rename(2, window, cx);
let target = app.tabs[2].tree_id.get();
let input = app.renaming.as_ref().expect("the box is up").input.clone();
input.update(cx, |s, cx| s.set_value("mine", window, cx));
// An unrelated close used to throw the half-typed name away
// (#598).
app.close_tab_inner(0, true, window, cx);
assert!(
app.renaming.is_some(),
"closing another tab keeps the rename box"
);
// So did a drag-reorder — and the index the commit once landed
// on had by then drifted onto a different tab.
let order: Vec<usize> = (0..app.tabs.len()).rev().collect();
app.apply_tab_order(&order, cx);
assert!(app.renaming.is_some(), "a reorder keeps the rename box");
app.commit_rename(window, cx);
let named: Vec<_> = app
.tabs
.iter()
.filter(|t| t.name.as_deref() == Some("mine"))
.collect();
assert_eq!(named.len(), 1, "the name landed on exactly one tab");
assert_eq!(
named[0].tree_id.get(),
target,
"and that tab is the one the box was opened on"
);
});
// Closing the renaming tab itself still ends the rename.
app.update_in(&mut vcx, |app, window, cx| {
app.start_rename(0, window, cx);
assert!(app.renaming.is_some());
app.close_tab_inner(0, true, window, cx);
assert!(app.renaming.is_none(), "losing its own tab closes the box");
});
}
}
// The output gate: a pane repaints on PTY output only while it is on screen.
// `Tty7App::render` declares the active tab's panes displayed each frame and
// everything else hidden; a pane nobody has declared — or whose id nobody
// registered — must err toward displayed, because the failure direction that
// matters is a visible pane that stops repainting.
#[cfg(test)]
mod displayed_gpui_tests {
use gpui::TestAppContext;
use crate::terminal::view::{declare_displayed, displayed_for_test, quiet_test_pane};
use crate::ui::app::test_window::harness_with_tabs;
fn pane_id(app: &super::Tty7App, tab: usize) -> gpui::EntityId {
app.tabs[tab]
.pane
.first_leaf()
.expect("tab has a pane")
.entity_id()
}
#[gpui::test]
fn the_output_gate_follows_the_active_tab(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
app.update_in(&mut vcx, |app, window, cx| {
let (front, back) = (pane_id(app, 0), pane_id(app, 1));
app.declare_displayed_panes(cx);
assert_eq!(
displayed_for_test(cx, front),
Some(true),
"the active tab's pane repaints on output"
);
assert_eq!(
displayed_for_test(cx, back),
Some(false),
"a background tab's pane stays out of the frame loop"
);
app.activate(1, window, cx);
app.declare_displayed_panes(cx);
assert_eq!(displayed_for_test(cx, front), Some(false));
assert_eq!(
displayed_for_test(cx, back),
Some(true),
"switching tabs hands the frame loop to the new tab"
);
});
}
#[gpui::test]
fn a_pane_nobody_declared_counts_as_displayed(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
let _orphan = app.update_in(&mut vcx, |app, window, cx| {
// A pane that exists but sits in no tab — the shape of every
// "path that never declares": it registers as displayed and a
// declaration pass over the app's own tabs leaves it alone.
let (view, stream) = quiet_test_pane(99, window, cx);
let orphan = view.entity_id();
assert_eq!(
displayed_for_test(cx, orphan),
Some(true),
"a fresh pane defaults to displayed"
);
app.declare_displayed_panes(cx);
assert_eq!(
displayed_for_test(cx, orphan),
Some(true),
"declaring only speaks about panes the app holds"
);
// An id nobody registered is declared into the void, not
// inserted: the registry only ever holds live panes' flags.
let ghost = gpui::EntityId::from(u64::MAX);
declare_displayed(cx, [(ghost, false)]);
assert_eq!(displayed_for_test(cx, ghost), None);
(view, stream)
});
}
#[gpui::test]
fn a_released_pane_leaves_the_registry(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
let back = app.update_in(&mut vcx, |app, _window, _cx| {
let back = pane_id(app, 1);
app.tabs.remove(1);
back
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |_, _, cx| {
assert_eq!(
displayed_for_test(cx, back),
None,
"a closed pane's flag does not outlive it"
);
});
}
}
// Zoom is a tab's view state: it rides with the tab across a switch, while a
// layout change (drag, split, close) still clears it.
#[cfg(test)]
mod zoom_gpui_tests {
use gpui::TestAppContext;
use crate::ui::app::test_window::harness_with_tabs;
#[gpui::test]
fn a_tabs_zoom_survives_a_round_trip_to_another_tab(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
app.update_in(&mut vcx, |app, window, cx| {
let leaf = app.tabs[0]
.pane
.first_leaf()
.and_then(|slot| slot.terminal().cloned())
.expect("tab 0 has a pane");
app.maximized = Some(leaf.clone());
app.activate(1, window, cx);
assert!(app.maximized.is_none(), "tab 1 never zoomed anything");
app.activate(0, window, cx);
assert_eq!(
app.maximized.as_ref().map(|l| l.entity_id()),
Some(leaf.entity_id()),
"tab 0's zoom is still where it was left (#599)"
);
// A zoom stashed for a pane that is no longer in the tab does not
// come back — it exited (or was closed) while the tab was away.
app.maximized = Some(leaf.clone());
app.activate(1, window, cx);
app.tabs[0].zoomed = Some(leaf);
app.tabs[0].pane =
crate::ui::pane::Pane::leaf(app.tabs[1].pane.first_leaf().expect("a donor leaf"));
app.activate(0, window, cx);
assert!(
app.maximized.is_none(),
"a stashed zoom whose pane is gone stays gone"
);
});
}
/// The mark the chrome wears (#752) has to go out again by every road the
/// zoom itself leaves by, and it has to name the right tab while several
/// tabs are each holding one.
#[gpui::test]
fn the_zoom_mark_is_on_whichever_tabs_are_hiding_panes(cx: &mut TestAppContext) {
use crate::terminal::view::quiet_test_pane;
use crate::ui::pane::{Pane, PaneSlot};
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
app.update_in(&mut vcx, |app, window, cx| {
// A zoom only hides something where there is a sibling to hide, so
// tabs 0 and 1 get a second pane and tab 2 stays single.
let mut held = Vec::new();
for tab in 0..2 {
let (view, stream) = quiet_test_pane(90 + tab as u64, window, cx);
held.push(stream);
let first = app.tabs[tab].pane.first_leaf().expect("tab has a pane");
app.tabs[tab].pane = Pane::split_node(
gpui::Axis::Horizontal,
0.5,
Pane::leaf(first),
Pane::leaf(PaneSlot::Ready(view)),
);
}
for i in 0..app.tabs.len() {
assert!(!app.tab_is_zoomed(i), "nothing is zoomed yet");
}
// Zooming marks the tab it happened in, and only that one.
app.toggle_maximize(window, cx);
assert!(app.tab_is_zoomed(0), "the zoomed tab wears the mark");
assert!(!app.tab_is_zoomed(1));
assert!(!app.tab_is_zoomed(2));
// And un-zooming takes it away again.
app.toggle_maximize(window, cx);
assert!(!app.tab_is_zoomed(0), "un-zooming clears the mark");
// The mark rides with its tab across a switch (#599) — an inactive
// tab holding a zoom still wears it — and two tabs can wear one at
// the same time, each reading its own handle.
app.toggle_maximize(window, cx);
app.activate(1, window, cx);
assert!(app.tab_is_zoomed(0), "the parked zoom is still a zoom");
assert!(!app.tab_is_zoomed(1));
app.toggle_maximize(window, cx);
assert!(app.tab_is_zoomed(0) && app.tab_is_zoomed(1));
assert!(!app.tab_is_zoomed(2), "a single-pane tab hides nothing");
// A parked zoom naming a pane that has since left the tab is no
// zoom: it would not come back on a switch, so it is not marked.
let parked = app.tabs[0].zoomed.clone().expect("tab 0 parked a zoom");
let elsewhere = app.tabs[2]
.pane
.first_leaf()
.and_then(|slot| slot.terminal().cloned());
app.tabs[0].zoomed = elsewhere;
assert!(
!app.tab_is_zoomed(0),
"a zoom over a pane this tab does not hold is not marked"
);
app.tabs[0].zoomed = Some(parked.clone());
assert!(app.tab_is_zoomed(0));
// Nor is a zoom over the last pane standing: its siblings closed
// while the tab was away, and the tab now looks like — and draws
// as — an ordinary single pane.
app.tabs[0].pane = Pane::leaf(PaneSlot::Ready(parked));
assert!(
!app.tab_is_zoomed(0),
"a zoom that covers nothing stops being marked"
);
assert!(!app.tab_is_zoomed(9), "there is no tab 9 to mark");
drop(held);
});
}
}
// A test window has no daemon behind it — its socket path is under the pinned
// test config dir and nothing is listening on it — so every forward request
// fails. That is exactly the case these are about: what the panel and the form
// are left holding when the far side does not answer.
#[cfg(test)]
mod managed_forward_gpui_tests {
use gpui::{Focusable as _, TestAppContext};
use gpui_component::input::InputState;
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
use crate::ui::app::test_window::harness_with_tabs;
fn listening(id: u64) -> ManagedForward {
ManagedForward {
id,
pane_id: 1,
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".to_string(),
bind_port: 8080,
target_host: "10.0.0.5".to_string(),
target_port: 80,
description: None,
status: ForwardStatus::Listening,
}
}
/// The form had no keyboard contract at all: no Return, no Escape, and it
/// opened cold, with the caret still in the terminal behind it. Every
/// sibling form in the app has all three.
///
/// Escape is a `on_key_down` on the form itself and only fires while
/// something inside it holds focus, so the focus below is what makes both
/// halves work; the subscriptions are what answer Return. Asserting on
/// both together is the point — arming one without the other is the state
/// this test exists to catch.
#[gpui::test]
fn opening_the_forward_form_arms_the_keyboard_and_closing_disarms_it(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |app, window, cx| {
assert!(
app.loopback_panel.mf_subs.is_empty(),
"nothing is listening before the form is up"
);
app.toggle_managed_forward_form(1, window, cx);
assert_eq!(
app.loopback_panel.form_pane_id,
Some(1),
"the form is up for the pane that asked"
);
assert_eq!(
app.loopback_panel.mf_subs.len(),
5,
"Return has to be answered on every box, not just the first"
);
assert!(
app.loopback_panel
.mf_bind_host
.read(cx)
.focus_handle(cx)
.is_focused(window),
"the form opens with the caret in Bind, so Escape reaches it too"
);
app.close_managed_forward_form(window, cx);
assert_eq!(app.loopback_panel.form_pane_id, None);
assert!(
app.loopback_panel.mf_subs.is_empty(),
"a live subscription on a box nothing is showing would answer \
Return for a form that is gone"
);
});
}
#[gpui::test]
fn an_add_that_never_reaches_the_session_leaves_the_panel_as_it_was(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |app, window, cx| {
app.loopback_panel.managed = vec![listening(1)];
app.loopback_panel.form_pane_id = Some(1);
// These three fields are the long form's; without this the short
// form would read them as the one number it asks for.
app.loopback_panel.mf_advanced = true;
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
(&app.loopback_panel.mf_bind_port, "9000"),
(&app.loopback_panel.mf_target_host, "127.0.0.1"),
(&app.loopback_panel.mf_target_port, "22"),
];
for (input, value) in typed {
input.update(cx, |input, cx| input.set_value(value, window, cx));
}
app.add_managed_forward(1, window, cx);
assert_eq!(
app.loopback_panel.managed.len(),
1,
"a request that failed says nothing about the forwards that are up"
);
assert!(
app.loopback_panel.mf_error.is_some(),
"and the form has to say why the Add did nothing"
);
assert_eq!(
app.loopback_panel.form_pane_id,
Some(1),
"the form stays open on what was typed"
);
});
}
#[gpui::test]
fn a_save_that_cannot_be_made_leaves_the_rule_it_would_replace_alone(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |app, window, cx| {
app.loopback_panel.managed = vec![listening(1)];
app.loopback_panel.form_pane_id = Some(1);
app.loopback_panel.mf_editing = Some(listening(1));
app.loopback_panel.mf_advanced = true;
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
(&app.loopback_panel.mf_bind_port, "8080"),
(&app.loopback_panel.mf_target_host, "10.0.0.6"),
(&app.loopback_panel.mf_target_port, "80"),
];
for (input, value) in typed {
input.update(cx, |input, cx| input.set_value(value, window, cx));
}
app.add_managed_forward(1, window, cx);
assert_eq!(
app.loopback_panel.managed,
vec![listening(1)],
"the rule being edited must survive an edit that could not be made"
);
assert!(
app.loopback_panel.mf_editing.is_some(),
"the form is still editing it"
);
assert!(app.loopback_panel.mf_error.is_some());
});
}
}
#[cfg(test)]
mod new_window_action_tests {
use crate::core::actions::NewWindow;
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::windows::WindowRegistry;
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
/// `NewWindow` has to open a window, not merely exist.
///
/// Everything else about the action is a table entry — the `actions!`
/// row, the keymap slot, the palette command — and every one of those can
/// be there while the action reaches nothing. This drives the real
/// dispatch path and then asks the registry, so the assertion is "a second
/// window is open, on a workspace of its own, and the first one is still
/// here": the same `windows::open` the switcher calls for "Open in New
/// Window", with no workspace named.
#[gpui::test]
fn dispatching_new_window_opens_a_second_window_beside_the_first(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
// Registered the way an opened window registers itself; without it the
// registry cannot tell the two windows apart afterwards.
let handle = window.into();
let weak = app.downgrade();
app.update(cx, |app, cx| {
WindowRegistry::register(cx, app.workspace, handle, weak);
});
let mut vcx = VisualTestContext::from_window(handle, cx);
vcx.background_executor.run_until_parked();
let first = app.update(&mut vcx, |app, _| app.workspace);
assert_eq!(
vcx.update(|_, cx| WindowRegistry::count(cx)),
1,
"the harness starts with exactly the one window"
);
vcx.dispatch_action(NewWindow);
vcx.background_executor.run_until_parked();
let open = vcx.update(|_, cx| WindowRegistry::open_windows(cx));
assert_eq!(
open.len(),
2,
"NewWindow has to reach windows::open; it opened {} window(s)",
open.len()
);
assert!(
open.iter().any(|(id, _)| *id == first),
"the window the action was fired from must survive it"
);
// The registry is keyed by workspace, so a second window on the
// current one is not a thing it could tell apart from the first.
assert!(
open.iter().any(|(id, _)| *id != first),
"the new window belongs on a workspace of its own"
);
}
/// The windowless state is the one `NewWindow` exists for.
///
/// `show_tray_icon` is on by default, so closing the last window retires
/// tty7 to the tray rather than quitting it: the process is alive, the
/// menu bar is still tty7's, and there is nothing on screen. A listener
/// that lives only on `Tty7App`'s render root reaches nothing there, and
/// the chord that means "give me a window" is the one chord that has to
/// answer. `App::dispatch_action` falls through to the global listeners
/// when no window is active, which is where `keymap::init` puts this one.
#[gpui::test]
fn new_window_answers_with_no_window_to_dispatch_it(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
assert_eq!(
WindowRegistry::count(cx),
0,
"the retired-to-tray state this covers has no window in it"
);
cx.dispatch_action(&NewWindow);
});
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
WindowRegistry::count(cx),
1,
"NewWindow has to reach windows::open with no window to bubble through"
);
});
}
}
#[cfg(test)]
mod close_window_action_tests {
use crate::core::actions::CloseWindow;
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::windows::WindowRegistry;
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
/// `CloseWindow` has to close the window, and close it the way the red
/// button does.
///
/// The action is otherwise all table entries — the `actions!` row, the
/// keymap slot, the palette command, the Keybindings label — and every one
/// of those can be in place while the action reaches nothing at all. So
/// this drives the real dispatch path and then asks two separate
/// questions: the window is gone from gpui, *and* it left the
/// `WindowRegistry` on the way out. The second is what makes it the same
/// close as the native one — `detach_workspace` is where the session is
/// saved and the workspace is retired, and a `remove_window` that skipped
/// it would still pass the first assertion while quietly dropping a
/// window's tabs on the floor.
#[gpui::test]
fn dispatching_close_window_takes_the_window_down_with_its_registration(
cx: &mut TestAppContext,
) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
// Registered the way an opened window registers itself; the registry
// is where the close has to show up, so an unregistered window would
// make the assertion below pass for the wrong reason.
let handle = window.into();
let weak = app.downgrade();
app.update(cx, |app, cx| {
WindowRegistry::register(cx, app.workspace, handle, weak);
});
let mut vcx = VisualTestContext::from_window(handle, cx);
vcx.background_executor.run_until_parked();
assert_eq!(
vcx.update(|_, cx| WindowRegistry::count(cx)),
1,
"the harness starts with exactly the one window"
);
vcx.dispatch_action(CloseWindow);
drop(vcx);
assert!(
cx.update(|cx| cx.windows().is_empty()),
"CloseWindow has to reach `remove_window`; the window is still open"
);
assert!(
cx.update(|cx| WindowRegistry::open_windows(cx).is_empty()),
"the close has to run the same cleanup the red button runs, \
which is what takes the window out of the registry"
);
}
}
/// #843: which pane a tab comes back to.
#[cfg(test)]
mod tab_focus_memory_tests {
use super::{Pane, PaneSlot, Tab, remember_leaf_in, replace_leaf_in};
use crate::ui::pending_pane::{PendingPane, PendingSpawn};
use gpui::{
AppContext as _, Axis, Context, Entity, IntoElement, Render, Styled as _, TestAppContext,
Window, div,
};
/// A window has to exist for focus to live in, but nothing this file asks
/// is about what a pane paints.
struct Blank;
impl Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full()
}
}
/// A leaf that owns a real focus handle without owning a shell. Focus
/// tracking asks the slot, not the terminal behind it, so a connecting
/// pane answers every question here exactly as a running one would — and
/// connecting panes are watched for focus now too, so this is not a
/// stand-in for the case under test but one of its cases.
fn leaf(cx: &mut Context<Blank>) -> Entity<PendingPane> {
cx.new(|cx| {
PendingPane::new(
"test",
PendingSpawn {
workspace: None,
working_directory: None,
restore_pane: None,
shell: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
owner: None,
font_size: 14.,
},
cx,
)
})
}
fn two_pane_tab(a: &Entity<PendingPane>, b: &Entity<PendingPane>) -> Tab {
Tab::new(Pane::split_node(
Axis::Horizontal,
0.5,
Pane::Leaf(PaneSlot::Connecting(a.clone())),
Pane::Leaf(PaneSlot::Connecting(b.clone())),
))
}
fn one_pane_tab(a: &Entity<PendingPane>) -> Tab {
Tab::new(Pane::Leaf(PaneSlot::Connecting(a.clone())))
}
/// The mechanism behind the report: `remember_active_pane`'s sample asks
/// which leaf holds focus *at that instant*, and a switch begun with focus
/// one handle away — the switcher's own search input, a palette closing,
/// the tab strip — finds no leaf and has nothing to write. This is why the
/// sample cannot be the only writer, and it is pinned here so that a change
/// making `focused_leaf` tolerant would have to argue with a test rather
/// than silently make this fix look unnecessary.
#[gpui::test]
fn a_switch_begun_off_the_panes_samples_nothing(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Blank);
let (a, b, elsewhere) = window
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), cx.focus_handle()))
.unwrap();
let tab = two_pane_tab(&a, &b);
window
.update(cx, |_, window, cx| {
let on_b = b.read(cx).focus_handle.clone();
window.focus(&on_b, cx);
assert_eq!(
tab.pane.focused_leaf(window, cx).map(|l| l.entity_id()),
Some(b.entity_id()),
"with focus in the pane the sample would have found it"
);
window.focus(&elsewhere, cx);
assert!(
tab.pane.focused_leaf(window, cx).is_none(),
"one handle off the pane and the switch-away sample has \
nothing to write"
);
})
.unwrap();
}
/// So focus-in writes instead, and the tab comes back to the pane focus
/// was last in even though it had wandered off the panes before the switch
/// ever started.
#[gpui::test]
fn a_tab_comes_back_to_the_pane_focus_was_last_in(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Blank);
let (a, b) = window.update(cx, |_, _, cx| (leaf(cx), leaf(cx))).unwrap();
let mut tabs = vec![two_pane_tab(&a, &b)];
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(a.entity_id()),
"a tab nobody has worked in yet still opens on its first leaf"
);
// The reader clicks into the right-hand pane: focus arrives, and that
// is the moment the tab is told.
remember_leaf_in(&mut tabs, b.entity_id());
// Focus then leaves the panes — the switcher opens, a palette closes —
// and the tab is switched away from. `remember_active_pane` finds no
// focused leaf and writes nothing, which is now harmless.
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(b.entity_id()),
"#843: the tab has to come back to the pane the reader was in"
);
}
/// A pane dragged into another tab is focused where it lands, so the
/// arrival has to change that tab's memory and not the one it left — the
/// reason the tab is found by the leaf rather than taken to be the active
/// one.
#[gpui::test]
fn a_moved_pane_is_remembered_by_the_tab_that_holds_it_now(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Blank);
let (a, b, c) = window
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx)))
.unwrap();
// Tab 0 is the active one and holds `a`; `b` and `c` live in tab 1.
let mut tabs = vec![one_pane_tab(&a), two_pane_tab(&b, &c)];
remember_leaf_in(&mut tabs, c.entity_id());
assert_eq!(
tabs[1].focus_target().map(|l| l.entity_id()),
Some(c.entity_id()),
"the tab holding the focused pane is the one that remembers it"
);
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(a.entity_id()),
"and no other tab's memory is touched"
);
}
/// A pane that arrives after its slot has gone — a spawn landing on a
/// closed tab, a leaf killed mid-flight — belongs to no tab, and must not
/// leave a memory behind for the first tab that happens to be looked at.
#[gpui::test]
fn a_leaf_no_tab_holds_is_recorded_nowhere(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Blank);
let (a, b, gone) = window
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx)))
.unwrap();
let mut tabs = vec![two_pane_tab(&a, &b)];
remember_leaf_in(&mut tabs, b.entity_id());
remember_leaf_in(&mut tabs, gone.entity_id());
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(b.entity_id()),
"a stranger's arrival leaves the tab's own answer alone"
);
}
/// A pane focused while it was still coming up is remembered under its
/// *pending* slot, and that id dies the moment the pane lands in its
/// place. The memory has to come along with the swap, or the landing is
/// itself what puts the tab back on its first leaf.
///
/// What lands here is another slot rather than a running pane: the swap has
/// to move an id from one slot to another, and which kind of slot arrived
/// is no part of the question.
#[gpui::test]
fn a_landing_pane_inherits_what_its_pending_slot_was_told(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| Blank);
let (a, connecting, landed, other) = window
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx), leaf(cx)))
.unwrap();
let mut tabs = vec![two_pane_tab(&a, &connecting)];
// Focus arrives while the pane is still connecting, then the pane it
// was waiting for lands in that slot.
remember_leaf_in(&mut tabs, connecting.entity_id());
replace_leaf_in(
&mut tabs,
connecting.entity_id(),
PaneSlot::Connecting(landed.clone()),
);
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(landed.entity_id()),
"#843: the memory follows the pane, not the slot it arrived in"
);
// A landing somewhere else in the tab is not an answer to this
// question and does not touch it.
replace_leaf_in(&mut tabs, a.entity_id(), PaneSlot::Connecting(other));
assert_eq!(
tabs[0].focus_target().map(|l| l.entity_id()),
Some(landed.entity_id()),
"another pane landing leaves the tab's answer alone"
);
}
/// The wiring, end to end. `watch_pane_focus` is the subscription that
/// writes the record, and a real round trip through `activate` has to come
/// back to the pane focus last arrived in — with focus off the panes well
/// before the switch was made, which is the moment the switch-away sample
/// cannot see (#843).
#[gpui::test]
fn a_tab_switch_returns_to_the_pane_focus_arrived_in(cx: &mut TestAppContext) {
use super::{test_window::harness, watch_pane_focus};
use crate::terminal::view::quiet_test_pane;
let (app, mut vcx) = harness(cx);
let (left, right, elsewhere, _held) = app.update_in(&mut vcx, |app, window, cx| {
let (left, left_stream) = quiet_test_pane(1, window, cx);
let (right, right_stream) = quiet_test_pane(2, window, cx);
let (only, only_stream) = quiet_test_pane(3, window, cx);
app.tabs.push(Tab::new(Pane::split_node(
Axis::Horizontal,
0.5,
Pane::leaf(PaneSlot::Ready(left.clone())),
Pane::leaf(PaneSlot::Ready(right.clone())),
)));
app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(only))));
app.active = 0;
// The subscription every spawn path registers for the pane it
// built.
for view in [&left, &right] {
let handle = view.read(cx).focus_handle.clone();
watch_pane_focus(&handle, view.entity_id(), window, cx);
}
cx.notify();
(
left,
right,
cx.focus_handle(),
(left_stream, right_stream, only_stream),
)
});
vcx.background_executor.run_until_parked();
// The reader clicks into the right-hand pane.
app.update_in(&mut vcx, |_, window, cx| {
let handle = right.read(cx).focus_handle.clone();
handle.focus(window, cx);
});
vcx.background_executor.run_until_parked();
// Focus then leaves the panes altogether — a palette closing, the tab
// strip, the switcher's own search input — before the tab is left.
app.update_in(&mut vcx, |_, window, cx| elsewhere.focus(window, cx));
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| app.activate(1, window, cx));
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| app.activate(0, window, cx));
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |_, window, cx| {
assert!(
right.read(cx).focus_handle.is_focused(window),
"#843: the tab has to come back to the pane the reader was in"
);
assert!(
!left.read(cx).focus_handle.is_focused(window),
"and not to the first leaf"
);
});
}
/// A split tab on screen with both panes watched the way every spawn path
/// watches them, plus a handle off the panes for focus to wander to.
#[allow(clippy::type_complexity)]
fn watched_split(
cx: &mut TestAppContext,
) -> (
Entity<super::Tty7App>,
gpui::VisualTestContext,
Entity<crate::terminal::view::TerminalView>,
Entity<crate::terminal::view::TerminalView>,
gpui::FocusHandle,
impl Sized,
) {
use super::{test_window::harness, watch_pane_focus};
use crate::terminal::view::quiet_test_pane;
let (app, mut vcx) = harness(cx);
let (left, right, elsewhere, held) = app.update_in(&mut vcx, |app, window, cx| {
let (left, left_stream) = quiet_test_pane(1, window, cx);
let (right, right_stream) = quiet_test_pane(2, window, cx);
app.tabs.push(Tab::new(Pane::split_node(
Axis::Horizontal,
0.5,
Pane::leaf(PaneSlot::Ready(left.clone())),
Pane::leaf(PaneSlot::Ready(right.clone())),
)));
app.active = 0;
for view in [&left, &right] {
let handle = view.read(cx).focus_handle.clone();
watch_pane_focus(&handle, view.entity_id(), window, cx);
}
cx.notify();
(left, right, cx.focus_handle(), (left_stream, right_stream))
});
vcx.background_executor.run_until_parked();
(app, vcx, left, right, elsewhere, held)
}
/// Zoom in on a pane, zoom back out, and the cursor is still in that pane
/// (#869). The pane being un-zoomed is the answer outright — it is the one
/// that was on screen to work in — so it is not a question for the tab's
/// memory, which only focus-in writes and which nothing guarantees is
/// current at the instant the toggle lands.
#[gpui::test]
fn unzooming_leaves_focus_in_the_pane_that_was_zoomed(cx: &mut TestAppContext) {
let (app, mut vcx, left, right, _elsewhere, _held) = watched_split(cx);
// Into the right-hand pane by keyboard, the road the report took.
app.update_in(&mut vcx, |app, window, cx| {
left.read(cx).focus_handle.clone().focus(window, cx);
app.cycle_pane(true, window, cx);
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| {
app.toggle_maximize(window, cx);
assert_eq!(
app.maximized.as_ref().map(|l| l.entity_id()),
Some(right.entity_id()),
"the pane focus is in is the one zoomed"
);
// Whatever the tab's memory says while zoomed — here the stale
// answer the report found — must not decide where un-zooming goes.
app.tabs[0].last_focused = Some(left.entity_id());
app.toggle_maximize(window, cx);
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| {
assert!(app.maximized.is_none());
assert!(
right.read(cx).focus_handle.is_focused(window),
"#869: un-zooming keeps focus in the pane that was zoomed"
);
assert_eq!(
app.tabs[0].last_focused,
Some(right.entity_id()),
"and the tab remembers it, so a switch away and back agrees"
);
});
}
/// Zooming with focus off the panes — a palette just closed, the tab strip
/// clicked — zooms the pane the tab remembers, not whichever leaf happens
/// to be first: the same pane switching back to the tab would focus.
#[gpui::test]
fn zooming_from_off_the_panes_zooms_the_remembered_pane(cx: &mut TestAppContext) {
let (app, mut vcx, _left, right, elsewhere, _held) = watched_split(cx);
app.update_in(&mut vcx, |_, window, cx| {
right.read(cx).focus_handle.clone().focus(window, cx);
});
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |_, window, cx| elsewhere.focus(window, cx));
vcx.background_executor.run_until_parked();
app.update_in(&mut vcx, |app, window, cx| {
app.toggle_maximize(window, cx);
assert_eq!(
app.maximized.as_ref().map(|l| l.entity_id()),
Some(right.entity_id()),
"the zoom lands on the pane the reader was last in"
);
assert!(right.read(cx).focus_handle.is_focused(window));
});
}
}