Files
tty7/src/ui/app.rs
T

6449 lines
284 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The window shell: a transparent unified title bar carrying the tab strip,
//! with the active terminal filling the rest. Owns all tabs (each its own PTY).
use gpui::{
App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div,
img, prelude::*, px,
};
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::collections::HashSet;
use std::rc::Rc;
use std::sync::Arc;
use crate::core::actions::*;
use crate::core::config::{
Config, CursorStyle as ConfigCursorStyle, NewTabPosition, RightPanelTab, ShellConfig,
TabBarPosition,
};
use crate::core::session::{
Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore,
};
use crate::core::shells::DetectedShell;
use crate::core::ssh_config;
use crate::core::window_state::WindowState;
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::palette::{
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
};
use crate::ui::pane::{CloseOutcome, Dir, Pane};
use crate::ui::presets::Fill;
use crate::ui::settings::{
Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action,
};
use crate::ui::theme::{apply_theme, set_menus, window_background};
/// One editable color of a user theme, targeted by the in-app color editor. Maps
/// a picker to the seed field (or ANSI slot) it writes back to the theme's file.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeEdit {
Background,
Foreground,
Accent,
Cursor,
Selection,
Ansi(usize),
}
/// Convert a picked `Hsla` to a `0xRRGGBB` value (alpha dropped) for storage in a
/// theme file.
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)
}
/// Global font-size bounds and step for the live zoom actions.
const FONT_SIZE_MIN: f32 = 6.0;
const FONT_SIZE_MAX: f32 = 48.0;
pub(crate) const FONT_SIZE_STEP: f32 = 1.0;
/// Line-height multiplier bounds and step for the Typography setting. 1.0 packs
/// rows flush against each other; 2.0 is very airy. 1.35 is the default.
const LINE_HEIGHT_MIN: f32 = 1.0;
const LINE_HEIGHT_MAX: f32 = 2.0;
pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05;
/// Cap on the recently-closed-tab stack, bounding memory and the JSON we'd
/// otherwise keep growing without limit.
const MAX_CLOSED_TABS: usize = 20;
/// How much one resize step nudges a split's ratio (see `resize_pane`). Matches
/// the divider's clamp band granularity in `pane.rs`.
const RESIZE_STEP: f32 = 0.05;
/// Quiet window after the last captured chord before a recorded shortcut is
/// committed (see `schedule_recording_commit`). Long enough to type a second
/// chord of a sequence (`ctrl-b x`), short enough that a single chord commits
/// promptly.
const RECORD_COMMIT_DELAY_MS: u64 = 650;
/// Height (px) of the unified title bar. Shared by `render` (the strip's height),
/// the settings overlay's nav-sidebar top zone, and the tab rail's top zone so
/// they all line up (and reach the very top of the window).
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
/// The chrome tile rhythm: a square hit box centred on a glyph, in two sizes —
/// the chrome's controls (title bar, rail, panel tabs, code header), and the
/// smaller ones that sit *inside* a panel's body, which have to read as
/// subordinate to the header above them.
///
/// The glyph sizes are nominal — the viewBox, not the mark. What they were
/// picked to land is ~10.8pt of actual *ink*, measured off a screenshot against
/// the macOS traffic lights (12pt across) in the same frame. That is a hair under
/// VS Code / Windsurf, which measure 12pt the same way, and well over the 8.4pt
/// these tiles drew before [`crate::ui::tab_strip::BUTTON_ICON_SCALE`] — the size
/// they had been pinned to regardless of what any call site asked for.
pub(crate) const TILE_SIZE: f32 = 32.;
pub(crate) const TILE_GLYPH: f32 = 13.;
pub(crate) const TILE_SIZE_SM: f32 = 24.;
pub(crate) const TILE_GLYPH_SM: f32 = 11.;
/// Line-art glyphs need a bigger nominal size than framed ones to draw the same
/// ink: in lucide's 24-unit box `plus` spans 5→19 where `panel-left` spans 3→21,
/// so at one shared size the "+" reads a fifth smaller than the tile beside it.
/// Sized off the measured ratios (58% against 72%), not the viewBox arithmetic.
/// (No `_SM` counterpart: every body-scale tile currently carries a framed mark.)
pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
/// Distance from a tile's edge to the glyph inside it — what anything lining a
/// tile up with text or with the window edge subtracts from the inset it wants,
/// so the *glyph* lands on the line rather than the invisible hit box around it.
///
/// Deliberately the nominal gap, not the distance to the glyph's ink. Counting
/// the transparent margin lucide leaves inside the mark is more accurate about
/// where the ink is, and useless: it makes `TILE_PAD` bigger than
/// [`CONTENT_INSET`], which drove [`tile_trailing_inset`] to under a pixel and
/// left the hover capsule looking sheared off against the window edge. The
/// capsule is a thing you can see; it can't be pushed off screen to put the
/// glyph a truer 2px to the right.
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.;
/// Help-menu destinations. The README already points people at these; the app
/// itself offered none of them, so the only in-product way to reach the docs or
/// the chat was to already know the URL.
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";
/// The one content inset the whole window aligns to: the rail's text and icons,
/// the title bar's chrome glyphs, and the side panels all start (or end) here, so
/// every vertical edge in the chrome falls on one of two lines rather than the
/// five slightly different ones each surface used to pick for itself.
pub(crate) const CONTENT_INSET: f32 = 12.;
/// Smallest gap between a tile's hit box — the capsule its hover and selected
/// states paint — and the window edge it sits against.
///
/// A floor, because the two rules that set that gap disagree once a tile is big
/// relative to [`CONTENT_INSET`]: aligning the glyph wants the box pushed out by
/// [`TILE_PAD`], and at `TILE_SIZE` 32 against an inset of 12 that leaves the
/// capsule flush with the edge, reading as clipped rather than aligned. Where
/// they conflict the visible thing wins.
const TILE_EDGE_GAP: f32 = 5.;
/// Trailing inset for a group of tiles that ends on the window's right edge: the
/// glyph on [`CONTENT_INSET`] where there is room for it, never closer to the
/// edge than [`TILE_EDGE_GAP`].
pub(crate) fn tile_trailing_inset() -> f32 {
(CONTENT_INSET - TILE_PAD).max(TILE_EDGE_GAP)
}
/// The same floor for the body-scale tiles inside a panel.
pub(crate) fn tile_trailing_inset_sm() -> f32 {
(CONTENT_INSET - TILE_PAD_SM).max(TILE_EDGE_GAP)
}
/// What gpui-component's `TitleBar` already insets its content by, to clear the
/// window controls: 80px on macOS (traffic lights on the left), 12px elsewhere
/// (controls on the right). Anything laid out *inside* the bar therefore starts
/// here, not at the window edge.
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
/// What the bar reserves at its *trailing* edge for the window controls: three
/// 34px tiles (─ ▢ ✕) off macOS, nothing on macOS (the traffic lights are on the
/// left, and `TITLE_BAR_LEAD` covers them). Anything in the bar that has to line
/// up with a column below it measures from the window edge minus this.
pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. };
/// Left offset for the tile group that sits beside the window controls.
///
/// On macOS the thing that can collide with the traffic lights is the tile's
/// *hit box* — it paints a background on hover and when selected, [`TILE_PAD`]
/// wider than the glyph on each side — so this aligns the box, not the glyph, and the
/// bar's own 80px lead is already exactly the clearance macOS defines for that.
/// Hence zero: pulling back into the reserve to "hug" the lights only made the
/// hover capsule touch them. Off macOS the controls are on the right, nothing is
/// there to clear, and the group aligns its glyph to the content inset like the
/// rest of the chrome.
pub(crate) fn title_bar_hug_offset() -> f32 {
if cfg!(target_os = "macos") {
0.
} else {
tile_trailing_inset() - TITLE_BAR_LEAD
}
}
/// Edge of the brand mark that anchors the window's leading corner off macOS
/// (see [`window_mark`]). Between a chrome tile's 32px hit box and its 13px
/// glyph: the mark paints no hover capsule, so what has to sit level with the
/// tiles beside it is its *ink* — and solid art reads heavier than line work at
/// equal size, hence short of the tile box rather than matching it.
pub(crate) const WINDOW_MARK_SIZE: f32 = 20.;
/// The "duo" mark — the same art the app icon and the About page carry — drawn
/// at the leading edge of the title-bar row, or `None` on macOS.
///
/// macOS owns that corner: the traffic lights sit there, and [`TITLE_BAR_LEAD`]
/// reserves them 80px. Everywhere else it is empty. The row's contents are the
/// rail's controls at its *right* end and the window chrome at the far side, so
/// the window's leading corner — the slot Windows reads as the app's identity,
/// filled by Explorer, VS Code and Zed alike — held nothing at all, which comes
/// across as unfinished rather than restrained.
///
/// Drawn, never clicked. It is not a menu button, so it stays out of the tile
/// rhythm (no hover capsule) and deliberately takes no `occlude()`: the row it
/// lives in is a `WindowControlArea::Drag`, and letting the mark fall through to
/// that keeps the strip grabbable instead of punching a dead 20px hole in it.
/// Make a row that stands in for the title bar behave like one: drag it to move
/// the window, double-click it to zoom.
///
/// Three rows do this. The rail's top zone sits level with the real bar but
/// outside it (the bar only spans the column beside the rail), and the code and
/// diff overlays each cover the bar with a header of their own drawn to its line.
/// Without this they are all dead strips: 40px across the top of the window that
/// look exactly like the caption and do nothing when you grab them.
///
/// Driven the way gpui-component's own `TitleBar` drives it — a press arms a
/// flag and the first *move* starts the window move — so a plain click, and a
/// double-click, still land intact. Note that on Windows the drag area maps to
/// HTCAPTION and the OS claims the press before gpui hit-tests, so every button
/// inside one of these rows needs an `occlude()` wrapper to get its clicks back.
pub(crate) fn title_bar_drag(row: gpui::Stateful<gpui::Div>) -> gpui::Stateful<gpui::Div> {
let should_move = Rc::new(Cell::new(false));
row.window_control_area(gpui::WindowControlArea::Drag)
.on_mouse_down(gpui::MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(true)
})
.on_mouse_up(gpui::MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(false)
})
.on_mouse_move(move |_, window, _| {
if should_move.replace(false) {
window.start_window_move();
}
})
.on_double_click(|_, window, _| {
// gpui only implements `titlebar_double_click` on macOS — the trait
// method is an empty default everywhere else, so on Linux this row
// swallowed the double-click and nothing zoomed. `zoom_window` is the
// maximise toggle there (x11 `_NET_WM_STATE_MAXIMIZED_*`, wayland
// `set_maximized`), and what gpui-component's own `TitleBar` calls on
// Linux for exactly this reason. Windows needs neither: the row is a
// drag area, which maps to HTCAPTION, and the OS has already restored
// or maximised the window before this could run.
if cfg!(target_os = "linux") {
window.zoom_window();
} else {
window.titlebar_double_click();
}
})
}
pub(crate) fn window_mark() -> Option<impl IntoElement> {
if cfg!(target_os = "macos") {
return None;
}
// Decoded once and shared: the title bar re-renders on every cursor blink,
// and building a fresh `Image` per frame would re-copy the PNG and miss
// gpui's image cache, which is keyed on the image's identity.
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())
}
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
/// tab is a real terminal tab.
pub struct Tab {
/// The tab's split-pane tree (one or more terminals).
pub pane: Pane,
/// User-set custom name (via "Rename Tab"). `None` → derive the label from
/// the focused terminal's title at render time.
pub name: Option<String>,
/// Entity id of the pane that last held focus in this tab. Recorded when we
/// leave the tab (see `remember_active_pane`) and restored on return, so
/// switching away and back keeps the active pane instead of jumping to the
/// first leaf. `None` for a tab never left, or after its focused pane closed
/// — both fall back to `first_leaf()`.
last_focused: Option<gpui::EntityId>,
/// `Some` while this tab has the working-tree diff overlay open (clicked
/// from a sidebar row's git line). Per-tab so switching away hides it and
/// switching back restores it; closing the tab drops it. Only the active
/// tab's overlay is rendered. See [`crate::ui::diff_overlay`].
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
/// This tab's code panel (file tree + editor overlay): open files, tree
/// roots/expansion, and visibility. Same per-tab contract as
/// `diff_overlay` — switching away hides it, switching back restores it,
/// closing the tab drops it. Shared caches (directory listings, gitignore
/// matchers, watchers) live on [`Tty7App`]. `None` until the panel is
/// first opened in this tab.
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
/// The sidebar group this tab last *definitively* belonged to: the
/// repository home of its first pane's cwd — the main checkout's root, so
/// linked worktrees of one repo share a group (deliberately not the
/// focused pane's cwd — switching focus between splits must not relocate
/// the row), or `None` for outside any repo (the "Scratch" group). Sticky
/// on purpose: it only moves when
/// the git cache has a landed answer for the current cwd
/// ([`GitStatusCache::known_repo_for`](crate::terminal::git_status::GitStatusCache::known_repo_for)
/// returns `Some`), so a cd whose probe is still in flight — or a pane
/// with no cwd reported yet — keeps the row where it was instead of
/// flickering through the Scratch group and back. A `RefCell` because the
/// sidebar refreshes it during render, which only has `&Tab`.
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
/// Which of the two full-column overlays (code panel, diff) was raised last.
/// They deliberately have no fixed precedence: whichever the user just acted
/// on paints on top, so opening a diff over the editor shows the diff, and
/// clicking a file in the tree behind it brings the editor back — the same
/// "click it, it comes forward" rule as window stacking.
pub(crate) overlay_top: OverlayTop,
}
/// Stacking order for the two overlays that cover the whole column. See
/// [`Tab::overlay_top`].
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum OverlayTop {
#[default]
Code,
Diff,
}
impl Tab {
fn new(pane: Pane) -> Self {
Self {
pane,
name: None,
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
sidebar_group: std::cell::RefCell::new(None),
}
}
/// The pane to focus when this tab becomes active: the last-focused leaf if
/// it still exists, otherwise the first leaf.
fn focus_target(&self) -> Option<Entity<TerminalView>> {
match self.last_focused {
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
None => self.pane.first_leaf(),
}
}
/// The pane the right panel's detail should describe. Not simply the
/// focused leaf: opening the panel, the diff overlay or the editor moves
/// focus off the terminal entirely, and `focused_or_first` would then fall
/// back to the *first* pane — so a split's second pane would silently swap
/// the panel's cwd the moment you interacted with the panel. Falling back to
/// `focus_target` uses the pane that held focus when it left instead, which
/// is the one the user still thinks of as active.
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())
}
/// The title used to derive the tab label: the pane the tab is working in.
/// Only the *active* tab has a live focused pane, so for an inactive tab
/// (which holds no window focus) we fall back to the pane it last had
/// focused (`focus_target`) rather than always its first leaf — otherwise a
/// background tab's label would snap to its first pane. Without a `window`
/// (e.g. the command palette) the same `focus_target` is the best we have.
/// Empty when there's no terminal or no title yet.
pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String {
let leaf = match window {
Some(window) => self
.pane
.focused_leaf(window, cx)
.or_else(|| self.focus_target()),
None => self.focus_target(),
};
leaf.map(|l| l.read(cx).title.clone()).unwrap_or_default()
}
/// The git snapshot (branch + working-tree diff) of the tab's label-driving
/// terminal — the focused leaf with a `window`, else the first — for the
/// sidebar row's branch line (the branch and change count shown under the
/// title). Read through the shared per-repo cache, so tabs in one work
/// tree always agree. `None` when that leaf isn't inside a git work tree,
/// or before the repo's first probe lands.
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(),
}?;
leaf.read(cx).git_status(cx)
}
/// The coding agent running in this tab, or `None`. Any leaf counts (a
/// split with a shell on the left and Claude on the right is an agent
/// tab); the first agent leaf in tree order wins. Drives the tab avatar's
/// brand mark.
pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> {
self.pane
.leaves()
.into_iter()
.find_map(|l| l.read(cx).agent())
}
/// The tab's most urgent agent status across its leaves — waiting beats
/// working beats done beats idle — or `None` when no leaf runs an agent.
/// The green `Done` state always shows (a finished turn stays visible until
/// the next one); [`agent_unread_count`](Self::agent_unread_count) then
/// says how many of those finished turns are unread. Drives the avatar dot
/// and the sidebar counts.
pub(crate) fn agent_status(&self, cx: &App) -> Option<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
.leaves()
.into_iter()
.filter(|l| l.read(cx).agent().is_some())
.map(|l| {
l.read(cx)
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle)
})
.max_by_key(|s| urgency(*s))
}
/// How many of the tab's panes hold an *unread* finished turn — a `Done`
/// the user hasn't looked at since. Drives the avatar dot's unread form:
/// the green dot swells into a count badge (a split tab can finish several
/// turns while you're away), and shrinks back to a plain dot once every
/// pane has been seen. Zero when the shown status isn't `Done` — a busier
/// pane (working/waiting) owns the corner until it settles.
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
.leaves()
.into_iter()
.filter(|l| {
let v = l.read(cx);
v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
&& v.agent_result_unread()
})
.count()
}
}
/// In-progress inline rename of a tab (double-click a tab label). Holds the
/// gpui-component text input plus the subscriptions that commit it on Enter/Blur.
pub(crate) struct Renaming {
/// Index of the tab being renamed, in `Tty7App::tabs`.
pub(crate) index: usize,
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
/// In-flight inline rename of the current workspace (the title-bar chip turns
/// into a text field). Mirrors [`Renaming`], but keyed to nothing — there is
/// only ever one current workspace per window.
pub(crate) struct WorkspaceRename {
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
pub(crate) struct LoopbackForwardPanelState {
/// The pane whose add/edit form is expanded under the Info tab's Forwards
/// band, or `None` while the band is just its list. Per-pane rather than a
/// bare flag so switching panes with a form open doesn't offer the new pane
/// a form half-filled with the old one's values.
pub(crate) form_pane_id: Option<u64>,
/// The unified forwards list (Local/Remote/Dynamic, including auto localhost
/// forwards) for the pane the Info tab is showing (WS4).
pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>,
/// Add-forward form state (native-SSH panes only).
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>,
/// When editing an existing forward, the id being edited — the form shows
/// Save/Cancel and re-establishes the forward on save. `None` = adding.
pub(crate) mf_editing: Option<u64>,
}
pub struct Tty7App {
/// The open tabs; each owns a split-pane tree and an optional name.
pub(crate) tabs: Vec<Tab>,
pub(crate) active: usize,
/// Current global font size (px), applied to every pane in every tab.
pub(crate) font_size: f32,
/// Current global line-height multiplier, applied to every pane.
pub(crate) line_height: f32,
/// Currently-applied font family. Tracked (not just read from config on
/// demand) so the `Config`-global observer can tell a hot-reloaded family
/// change from the far more common no-op re-notify.
pub(crate) font_family: String,
/// Currently-applied distinct bold/italic faces (`None` = synthesized), also
/// tracked so the hot-reload observer can diff them like `font_family`.
pub(crate) font_family_bold: Option<String>,
pub(crate) font_family_italic: Option<String>,
/// Currently-applied OpenType features for terminal fonts. `None` means the
/// terminal-safe default (ligatures disabled).
pub(crate) font_features: Option<gpui::FontFeatures>,
/// Currently-applied terminal-emulator defaults. Tracked so hot-reload can
/// push only the alacritty-backed options that actually changed.
terminal_cursor_style: ConfigCursorStyle,
terminal_scrollback_limit: usize,
/// Keeps the `observe_global::<Config>` subscription alive for the app's
/// lifetime so external edits to `config.json` (swapped in by the watcher in
/// `main.rs`) live-apply font size / line height / family. Never read.
_config_watch: Subscription,
/// Keeps the keystroke interceptor alive: any real keypress cancels the
/// held-⌘/Ctrl tab badges (and any pending reveal), so a chord like ⌘C
/// never shows them — only a bare hold does. An *interceptor* (fires
/// pre-dispatch) rather than an observer because the terminal consumes
/// most keys with `stop_propagation`, which suppresses observers. Never read.
_keystroke_watch: Subscription,
/// Keeps the window-activation observer alive: any active-status flip also
/// cancels the badges. Deactivating mid-hold (⌘-Tab, Spotlight, a click
/// into another app) sends the modifier *release* to whatever app is key
/// by then — this window never gets that `ModifiersChanged`, so without
/// this the badges stuck on until some later keypress. Never read.
_activation_watch: Subscription,
/// Keeps the `observe_global::<GitStatusCache>` subscription alive: a git
/// probe landing (from *any* pane) repaints the sidebar, so every row in
/// the same repo shows the just-refreshed branch/diff line, not a stale
/// per-row copy. Never read.
_git_status_watch: Subscription,
/// Keeps the window-appearance observer alive: while
/// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves
/// the theme slot and repaints. Never read.
_appearance_watch: Subscription,
/// `Some` while the command palette overlay is open; `None` when closed.
/// The view owns its search input, filtered list and keyboard handling and
/// emits a `PaletteEvent`; we build the catalog and run the chosen command.
palette: Option<Entity<PaletteView>>,
/// Keeps the open palette's event subscription alive; dropped on close.
palette_sub: Option<Subscription>,
/// Stack of recently closed tabs (most recent on top) for Cmd+Shift+T.
/// Stored serialized so each entry carries the panes' cwd + name at close.
/// `pub(crate)` so the home page can surface the top entry as its
/// "reopen what you just closed" hint.
pub(crate) closed: Vec<SessionTab>,
/// `Some` while a tab label is being renamed inline; `None` otherwise.
pub(crate) renaming: Option<Renaming>,
/// `Some` while the "New Worktree Tab" sheet is open (see
/// `ui::worktree_prompt`); `None` otherwise.
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
/// When `Some`, the active tab renders only this one leaf full-window
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
maximized: Option<Entity<TerminalView>>,
/// Whether the tab chips currently show their ⌘1…⌘9 switch badges
/// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`).
pub(crate) mod_hint_badges: bool,
/// Generation counter for the delayed badge reveal: bumped on every
/// modifier transition and keypress so a stale timer can't fire.
pub(crate) mod_hint_gen: u64,
/// Generation counter for the keybinding-capture commit timer: bumped on
/// every captured chord, cancel, and start, so a stale pause-to-commit
/// timer can't fire after the sequence changed or capture ended.
record_gen: u64,
/// Focus target for the home page (the zero-tab state; see `ui::home`).
/// Keeping something focused keeps keystrokes flowing through the window's
/// dispatch path, so ⌘T & friends still reach the root action handlers.
pub(crate) home_focus: gpui::FocusHandle,
/// Shells found on this machine (`core::shells::detect_shells`), listed in
/// the "+" dropdown. Probed once at startup off the UI thread — empty until
/// that lands, when the dropdown offers just the default entry.
pub(crate) detected_shells: Vec<DetectedShell>,
/// Pane-contextual SSH loopback forward UI state. The controls render only
/// over the active SSH pane, but the input/editing state is app-owned so it
/// is not tied to the Settings tab.
pub(crate) loopback_panel: LoopbackForwardPanelState,
/// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane.
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
/// Right detail panel (info / changes / files) docked beside the terminal.
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
/// Local project file tree (left column of the body).
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
/// Code-editor panel (right column of the body).
pub(crate) editor: crate::ui::code_editor::EditorPanelState,
/// Vertical tab sidebar width (px), held in a shared `Cell` so the resize
/// drag's window-level mouse listener can mutate it without the entity handle
/// (mirrors the split divider's `ratio`). Seeded from `Config::sidebar_width`
/// and persisted back when a drag ends.
pub(crate) sidebar_width: Rc<Cell<f32>>,
/// Whether the sidebar's resize handle is currently held.
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
/// Right detail panel width (px) and drag state, held in shared `Cell`s for
/// exactly the reason `sidebar_width` is — see there.
pub(crate) right_panel_width: Rc<Cell<f32>>,
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
/// Which chrome this *window* is showing: is the detail panel docked open,
/// which of its tabs is selected, and is the tab rail collapsed.
///
/// Window-level rather than `Config`, which is a global: with one window the
/// two were indistinguishable, but with several, reading the config meant
/// opening the detail panel in one window opened it in every other one too.
/// A window is a *view* — what it has on screen is its own. The config
/// fields of the same names survive as what a newly opened window starts
/// with, written back on each toggle so a new window (and the next launch)
/// inherits the last thing the user actually chose.
pub(crate) right_panel_visible: bool,
pub(crate) right_panel_tab: RightPanelTab,
pub(crate) sidebar_collapsed: bool,
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
/// row into view — and so the rail's overlay scrollbar has an offset to
/// track and drag (see [`crate::ui::scrollbar`]).
pub(crate) sidebar_scroll: gpui::ScrollHandle,
/// `Some` while a tab / group is being dragged to a new position, in either
/// the strip or the rail: the frozen geometry the live preview reflow is
/// computed against (see [`crate::ui::reorder`]). Shared by `Rc` because the
/// `on_drag` that opens it only gets `&mut App`, not the entity. Cleared on
/// the first frame after gpui ends the drag.
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
pub(crate) sidebar_search: Entity<InputState>,
/// Live filter for the detail panel's Files tab (its own box, so filtering
/// the tree never disturbs the tab list's filter and vice versa).
pub(crate) file_search: Entity<InputState>,
/// Re-renders the sidebar on each search keystroke so results narrow live.
_sidebar_search_sub: Subscription,
_file_search_sub: Subscription,
/// `Some` while the settings page is open. Settings is a full-window overlay
/// (not a tab), so it covers the tab rail / title bar and never clutters the
/// tab list. Holds all the settings widget state + its subscriptions.
settings: Option<SettingsState>,
/// In-pane native-SSH auth / host-key sheet state (WS3). Holds the active
/// prompt (keyed to the pane that raised it), its input widgets, and
/// dismissable banners. Empty when no prompt is pending.
pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState,
/// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close
/// action awaiting confirmation, or `None` when no prompt is up.
pub(crate) ssh_close_confirm: Option<SshCloseKind>,
/// Latest window geometry (the restore bounds while fullscreen), kept
/// current by a bounds observer so the quit hook can persist it to
/// `window.json` — at quit time no `&Window` is in reach to ask directly.
window_bounds: Bounds<Pixels>,
/// Which persistent workspace this window is showing. The window is the
/// transient view; the workspace is the identity that survives closing it
/// and shows up in the home-page picker. Every `save_session` writes back
/// under this id, so two windows never overwrite each other's tabs.
pub(crate) workspace: WorkspaceId,
/// Cached "which daemon panes are alive", with the instant it was taken.
/// The picker needs it per row, but answering costs a control connection to
/// the daemon — far too much to pay on every frame — and the answer only
/// changes when a shell exits. A short TTL keeps it honest without making
/// rendering do IO.
pub(crate) alive_cache: RefCell<Option<(std::time::Instant, HashSet<u64>)>>,
/// `Some` while the title-bar workspace chip is being renamed inline.
/// Separate from `renaming` (tabs) because the two live in different
/// widgets and can't be in flight at once anyway.
pub(crate) workspace_rename: Option<WorkspaceRename>,
/// Last title pushed to the OS window, so the common case (nothing
/// changed) skips the platform call. `RefCell` because the sync runs from
/// `focus_active`, which only takes `&self`.
window_title: std::cell::RefCell<String>,
}
/// Which close action a live-SSH close-confirmation is gating (PRD FR-E3).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshCloseKind {
/// Close the whole tab at this index.
Tab(usize),
/// Close the focused pane.
Pane,
}
impl Tty7App {
/// A window on `id`'s workspace — reopening one from the picker — or on a
/// fresh workspace when `id` is `None` (New Workspace) or names a workspace
/// that is no longer on file.
pub fn for_workspace(
id: Option<WorkspaceId>,
fresh: crate::ui::windows::FreshStart,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Claiming marks the workspace open and hands back its saved tabs, so
// the store (not this window) stays the single writer of session.json.
let restore = cx.global::<Config>().restore_session;
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
let (workspace, saved) = WorkspaceStore::claim(cx, id);
// A workspace that was already on file restores its tab/split layout and
// each pane's cwd, unless the user turned restore off — then it starts
// fresh. A *brand-new* one has no tabs to restore, so what it comes up
// with is the caller's call: `None` here takes the first-run path in
// `with_session`, spawning a single default terminal, which is what
// `New Workspace` and a first run both want. Handing an empty session
// through instead lands on the home page, for the launch that exists to
// show the workspace picker.
let session = match (known, fresh) {
(true, _) => restore.then_some(saved),
(false, crate::ui::windows::FreshStart::Shell) => None,
(false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()),
};
let app = Self::with_session(Some(workspace), session, window, cx);
// Persist right away. The leaves just spawned (or reattached) now carry
// daemon pane ids, and nothing else writes them until the next
// *structural* change — so a crash before the user happens to open a
// tab would strand every one of those panes in the daemon.
app.save_session(cx);
// If startup reused a daemon that speaks a different wire protocol
// (an app upgrade while the old service kept running), the sessions
// just restored above are living on that old dialect. Surface the
// keep-or-restart choice now that there's a window to ask in.
Self::prompt_daemon_version_mismatch(window, cx);
app
}
/// Ask what to do about a protocol-mismatched daemon that
/// `spawn::ensure_running` deliberately left running (rather than silently
/// killing every persisted session at startup): keep using it — sessions
/// survive, features whose wire shape changed may misbehave — or restart
/// the service clean via the shared
/// [`restart_daemon_confirmed`](Self::restart_daemon_confirmed) path
/// (tabs reopen with fresh shells). Keeping is the default: dismissing
/// the prompt changes nothing.
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.version {
Some(v) => format!(
"The daemon holding your sessions is from another build \
(v{}, protocol {} — this app speaks {}). You can keep using it and \
your sessions stay, but features whose wire format changed may \
misbehave until it's restarted. Restarting starts a clean daemon: \
tabs reopen with fresh shells and anything running in them is \
terminated.",
v.build, v.protocol, ours
),
None => "The daemon holding your sessions is from an older \
version of the app. You can keep using it and your sessions stay, \
but newer features may misbehave until it's restarted. Restarting \
starts a clean daemon: tabs reopen with fresh shells and anything \
running in them is terminated."
.to_string(),
};
// Phrased as the question it is, like every other prompt in the app —
// this one used to be a bare statement of fact with two verbs under it.
// The version details it used to carry in the title are in the body.
let answer = window.prompt(
PromptLevel::Warning,
"Restart Daemon?",
Some(&detail),
&["Keep Sessions", "Restart"],
cx,
);
cx.spawn(async move |this, cx| {
// Index 1 == "Restart"; "Keep Sessions" or a dismissed prompt leave
// the old daemon (and every session) untouched.
if !matches!(answer.await, Ok(1)) {
return;
}
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
})
.detach();
}
/// The whole constructor behind `new`, with the saved session injected
/// instead of read from disk. The headless tests build the app through
/// this seam (a zero-tab session → the home page, no terminal spawned)
/// so every subscription and window hook runs exactly as in production
/// without touching `~/.config` or a daemon.
pub(crate) fn with_session(
workspace: Option<WorkspaceId>,
session: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Tests build a window without going through the store; give them a
// detached identity rather than requiring the global to be installed.
let workspace = workspace.unwrap_or_default();
// Font size from config (borrow ends before the mutable theme apply).
let (
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
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_features.clone(),
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);
// Managed-forward add-form inputs (native-SSH panes).
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("description"));
let sidebar_width = cx.global::<Config>().sidebar_width;
let right_panel_width = cx.global::<Config>().right_panel_width;
// The config's copies are this window's *starting* chrome; from here on
// the window owns them (see the fields' doc comment).
let right_panel_visible = cx.global::<Config>().right_panel_visible;
let right_panel_tab = cx.global::<Config>().right_panel_tab;
let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed;
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
// `Config` global on every `config.json` change, which fires this. The
// window-aware variant so the reload can re-run `apply_theme` with the
// window (blur flip, traffic-light pinning) and re-sync the Appearance
// opacity slider — the watcher task itself has no window handle.
let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| {
this.reload_from_config(window, cx)
});
// Repaint when any pane's git probe lands in the shared cache — the
// sidebar's branch/diff lines read from it, and the probing pane's own
// notify wouldn't re-render rows belonging to *other* panes. The open
// diff overlay rides the same signal: if the landed numbers disagree
// with what it shows, it re-probes the full diff.
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);
// Same trigger, same freshness: the right panel's Changes list is
// the sidebar's `+N M` expanded, so it re-probes whenever those
// numbers do rather than going stale behind them.
this.right_panel_refresh_changes(cx);
cx.notify();
});
// Any real keypress means "chord, not a bare hold": cancel the held-⌘
// tab badges and whatever reveal is pending (see `ui::hints`).
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));
});
// Losing key status mid-hold (⌘-Tab, Spotlight, a click into another
// app) means the modifier release is delivered elsewhere and never
// reaches this window — the activation flip is the only signal left,
// so treat it like a release. Dismissing on *both* flips also keeps a
// reveal scheduled just before the switch from popping the badges up
// in a window the user already left.
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
this.dismiss_mod_hint(cx);
// The panes' link-modifier tracking loses the release the same
// way, and a stale "⌘ held" is worse than missing badges: a
// plain unmodified click would open links. Treat the flip as a
// release; holding ⌘ again re-arms it via `on_modifiers_changed`.
this.set_link_modifier(false, cx);
// Coming back is the only cue we get that the working tree may
// have moved while the user was elsewhere: an edit in another
// editor, a `git` command in another app, an agent in another
// window. None of those reach a pane's poll loop, so without this
// the sidebar's `+N N` would keep showing pre-alt-tab numbers
// until the user happened to run a command in the pane.
if window.is_window_active() {
// Whichever window the user last brought forward is the one to
// focus on the next launch — `claim` only ever records the
// *last opened* workspace, which is a different thing.
WorkspaceStore::focus(cx, this.workspace);
this.refresh_git_status_all(cx);
}
});
// Follow OS light/dark flips live: while "sync with system" is on, an
// appearance change re-resolves the theme slot and repaints. While it's
// off the appearance only ever changes because `apply_theme` pinned it
// to the theme — skip, or the pin would re-trigger a redundant apply.
let this = cx.weak_entity();
let appearance_watch = window.observe_window_appearance(move |window, cx| {
// This is the only place the OS flip is observed, so cache it here —
// from the *window*, never `cx.window_appearance()`, which would
// re-enter gpui's already-borrowed Linux client and panic. Before the
// early return, so a flip that happens while following is off still
// lands. See `ui::theme::SystemAppearance`.
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| {
// The editor targets the on-screen theme, and with no global
// override the opacity slider follows it — keep both in step.
this.rebuild_theme_editor(window, cx);
this.sync_window_opacity_slider(window, cx);
cx.notify();
});
});
// Paint the configured color theme (defaults to a light one) and build
// the menu bar.
apply_theme(Some(window), cx);
set_menus(cx);
// A session with zero tabs is a real state — the user quit from
// the home page — and restores back to it; only a *missing/unreadable*
// session (first run) falls back to spawning a default terminal.
let (tabs, active) = match session {
// First run (no session file): the very first terminal has no
// predecessor to inherit from, so start in the app's current
// directory (None → default behavior).
None => match new_terminal(font_size, None, None, None, window, cx) {
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
// The daemon we just tried to start isn't answering. A window
// with no tabs is a legal state (it shows the home page), and
// far better than taking the launch down over it.
Err(e) => {
log::error!("first terminal failed to start: {e}");
(Vec::new(), 0)
}
},
// A saved session (with tabs, or an empty home-page state): rebuild it
// the same way a daemon restart does.
some => tabs_from_session(some, font_size, window, cx),
};
// Sidebar tab filter. Each keystroke re-renders the (cheap) row list so
// results narrow as you type — the same live-filter wiring the theme
// picker uses.
let sidebar_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search tabs…"));
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("Search files…"));
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let app = Self {
tabs,
active,
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
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,
_appearance_watch: appearance_watch,
palette: None,
palette_sub: 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(),
detected_shells: Vec::new(),
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,
},
sftp_panel,
right_panel: Default::default(),
file_tree,
editor,
sidebar_width: Rc::new(Cell::new(sidebar_width)),
sidebar_dragging: Rc::new(Cell::new(false)),
right_panel_width: Rc::new(Cell::new(right_panel_width)),
right_panel_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)),
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),
ssh_close_confirm: None,
window_bounds: window.window_bounds().get_bounds(),
workspace,
alive_cache: RefCell::new(None),
workspace_rename: None,
window_title: std::cell::RefCell::new(String::new()),
};
// Bring the system tray up (icon + agent menu + poll loop) — but only
// for the *first* window: the tray is one app-wide icon, and letting
// every window register its own would stack N icons in the status bar.
// `register` happens after `open_window` returns, so during the first
// window's construction the registry is still empty.
//
// Skipped in tests: the headless harness has no native status bar to
// register with, and the poll task would just spin against the mocked
// clock.
if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 {
crate::ui::tray::init(cx);
}
// Discover this machine's shells for the "+" dropdown off the UI thread
// (the WSL probe on Windows spawns a process, and /etc/shells hits the
// filesystem). Until it lands the dropdown offers just the default entry.
cx.spawn(async move |this, cx| {
let shells = cx
.background_spawn(async { crate::core::shells::detect_shells() })
.await;
// `notify` so the strip re-renders and the dropdown closure
// captures the freshly landed list (nothing else is guaranteed to
// redraw an idle window).
let _ = this.update(cx, |app, cx| {
app.detected_shells = shells;
cx.notify();
});
})
.detach();
// Persist the session one last time as the app quits. This captures the
// latest state — including a plain `cd` that changed a pane's cwd but
// triggered no structural change — so the next launch restores where the
// user actually left off. The callback gets the live `Tty7App`, reads
// every pane's current cwd, and writes the file synchronously; the empty
// future just satisfies the hook's async signature. The subscription is
// detached to live for the app's lifetime (its weak handle keeps it safe
// after teardown).
cx.on_app_quit(|app, cx| {
app.save_session(cx);
// Also persist the window's final geometry so the next launch can
// reopen there (`remember_window_size`). Written unconditionally —
// startup gates on the config — so toggling the setting back on
// restores the most recent quit, not some stale pre-toggle state.
crate::core::window_state::WindowState::from_bounds(app.window_bounds).save();
async move {}
})
.detach();
// Keep `window_bounds` tracking the live window: moves and resizes both
// fire this observer, and `window_bounds()` reports the *restore* bounds
// while fullscreen, so a fullscreen quit doesn't record a screen-sized
// window for the next normal launch.
cx.observe_window_bounds(window, |this, window, _cx| {
this.window_bounds = window.window_bounds().get_bounds();
})
.detach();
// Closing a window *detaches* its workspace: the panes keep running in
// the daemon, and the workspace drops into the home-page picker to be
// reopened later. So closing one of several windows is cheap and needs
// no confirmation — the user can see the others and get this one back.
//
// The last window is different: closing it also quits the app (a
// windowless process left in the Dock no longer responds to being
// clicked — #147), so that one keeps the reassuring prompt by default.
// We veto the immediate close (return `false`), show it, and quit only
// if the user picks "Close"; a one-shot flag lets that post-confirm
// close through instead of looping the prompt.
//
// `confirm_window_close` turns the prompt off for users who have learned
// the model — it is teaching, not protection (⌘Q never asked), so it has
// to be escapable.
let close_confirmed = std::rc::Rc::new(std::cell::Cell::new(false));
let weak_app = cx.weak_entity();
window.on_window_should_close(cx, move |window, cx| {
if close_confirmed.get() {
return true;
}
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
let empty = weak_app
.upgrade()
.is_some_and(|app| app.read(cx).tabs.is_empty());
// Any window but the last, an empty one with nothing to reassure
// about, or a user who has turned the prompt off: detach and go.
// Prompting here would be friction.
let confirm = cx.global::<Config>().confirm_window_close;
if !last_window || empty || !confirm {
if let Some(app) = weak_app.upgrade() {
app.update(cx, |app, cx| app.detach_workspace(cx));
}
if last_window {
// Deferred onto the next tick so the close itself completes
// first, same as the confirmed path below.
cx.spawn(async move |cx| {
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
return true;
}
let answer = window.prompt(
PromptLevel::Info,
"Close Window?",
// What this promises has to match what the next launch does.
// Closing the last window *detaches* its workspace rather than
// ending it: the panes keep running in the daemon, but tty7
// comes back on the home page with the workspace waiting in the
// picker — it no longer reopens it unasked, so promising it
// would be restored would be a promise the app doesn't keep.
//
// Points at the title bar's workspace menu, not the macOS
// Window menu: there is no menu bar on Windows or Linux, and
// the corner chip is the one place that lists workspaces on
// every platform.
Some(
"Your sessions keep running in the background. This \
workspace will be waiting on the home page, and in the \
workspace menu in the title bar, the next time you open \
tty7.",
),
&["Cancel", "Close"],
cx,
);
let close_confirmed = close_confirmed.clone();
let weak_app = weak_app.clone();
cx.spawn(async move |cx| {
// Index 1 == "Close"; index 0 (Cancel) and a dismissed prompt
// both leave the window open.
if let Ok(1) = answer.await {
close_confirmed.set(true);
let _ = cx.update(|cx| {
if let Some(app) = weak_app.upgrade() {
app.update(cx, |app, cx| app.detach_workspace(cx));
}
cx.quit();
});
}
})
.detach();
false
});
app.focus_active(window, cx);
app
}
/// Snapshot the current tabs/active index into a `Session` and persist it.
/// Called after every structural change; the write is a small synchronous
/// JSON dump and any error is swallowed inside `Session::save`.
pub(crate) fn save_session(&self, cx: &mut App) {
let tabs: Vec<SessionTab> = self
.tabs
.iter()
.map(|tab| tab_to_session(tab, cx))
.collect();
// Zero tabs is a real state (the home page) and is persisted as such, so
// the next launch comes back to it instead of a fresh shell.
let active = if tabs.is_empty() {
0
} else {
self.active.min(tabs.len() - 1)
};
let session = Session { active, tabs };
// The store merges this into the other windows' workspaces and owns the
// write; the geometry rides along so reopening lands where we are now.
WorkspaceStore::record(
cx,
self.workspace,
session,
Some(WindowState::from_bounds(self.window_bounds)),
);
}
/// This window is going away: capture its final state (a plain `cd` may
/// have moved a pane's cwd with no structural change to trigger a save),
/// mark the workspace closed so the home-page picker lists it, and drop it
/// from the registry so "is this the last window?" stays accurate.
///
/// A *detach*, not a teardown — the daemon panes keep running and reattach
/// when the workspace is reopened.
pub(crate) fn detach_workspace(&self, cx: &mut App) {
self.save_session(cx);
// An empty workspace has nothing to come back to, so it is dropped
// outright instead of accumulating as a blank row in the picker —
// every `New Workspace` the user closes without using would leave one.
let name = if self.tabs.is_empty() {
WorkspaceStore::remove(cx, self.workspace);
String::new()
} else {
WorkspaceStore::close_window(cx, self.workspace);
// Read after the update so the name reflects what was just stored.
WorkspaceStore::all(cx)
.get(self.workspace)
.map(|w| w.display_name())
.unwrap_or_default()
};
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
// The workspace just moved from "on screen" to "detached" — the Window
// menu is the only place that says so.
crate::ui::windows::refresh_menu(cx);
// ...and the first time that happens, say it out loud once. Only for a
// workspace with something in it: putting away an empty window teaches
// nothing (and it was dropped outright above).
if !name.is_empty() {
crate::ui::windows::hint_detached(cx, &name);
}
}
/// Stop a workspace — kill its sessions and close its window — confirming
/// first when something is still running. Its layout stays on file, so it
/// can be started again later.
///
/// Deliberately not called "close": the red traffic light closes a window
/// and only detaches, while this ends the shells. Two actions that sit near
/// each other need two different verbs, or the menu reads as if they were
/// variations on one thing.
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();
}
/// Delete a workspace: stop it *and* discard the saved layout.
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();
}
/// Show the workspace in the Window menu's slot `index`. A stale slot (the
/// menu was built before a workspace was stopped) is a no-op rather than an
/// error — the menu is rebuilt right after any such change anyway.
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);
}
/// Show `id`'s workspace.
///
/// One workspace is shown by exactly one window, so this either focuses the
/// window it already has or opens a new one for it — never swaps it into
/// *this* window, which would leave the workspace already here without one.
///
/// The single exception is a window that is empty (the home page): reusing
/// it beats opening a second window and stranding a blank frame, and there
/// is no workspace to displace.
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;
}
if self.tabs.is_empty() {
self.switch_workspace(id, window, cx);
} else {
crate::ui::windows::open(cx, Some(id));
}
}
/// Swap this window over to `id`'s workspace in place, rebuilding its tabs.
///
/// This is what the home-page picker does: the window running it is empty
/// (the picker only shows on the home page), so opening a *second* window
/// would strand this blank one. The outgoing workspace is dropped rather
/// than detached for the same reason.
pub(crate) fn switch_workspace(
&mut self,
id: WorkspaceId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous = self.workspace;
if previous == id {
return;
}
if self.tabs.is_empty() {
WorkspaceStore::remove(cx, previous);
} else {
self.save_session(cx);
WorkspaceStore::close_window(cx, previous);
}
let (claimed, session) = WorkspaceStore::claim(cx, Some(id));
crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed);
self.adopt_workspace(claimed, session, window, cx);
}
/// Take over an *already claimed* workspace: rebuild this window's tabs
/// from `session` and retitle it. Split from [`Self::switch_workspace`]
/// because `ui::windows::close_workspace` gets here having already
/// destroyed the outgoing workspace — there is nothing left to detach.
pub(crate) fn adopt_workspace(
&mut self,
id: WorkspaceId,
session: Session,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.workspace = id;
let font_size = self.font_size;
let (tabs, active) = tabs_from_session(Some(session), font_size, window, cx);
self.tabs = tabs;
self.active = active;
self.maximized = None;
// Same reason as `for_workspace`: capture the reattached/spawned pane
// ids now rather than waiting for a structural change.
self.save_session(cx);
crate::ui::windows::refresh_menu(cx);
self.focus_active(window, cx);
cx.notify();
}
/// Reopen the most recently closed tab (Cmd+Shift+T). Rebuilds its pane
/// tree (restoring each terminal's saved cwd), inserts it after the active
/// tab, and focuses it. No-op when the stack is empty.
fn reopen_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(st) = self.closed.pop() else {
return;
};
let alive = alive_panes();
let Some(pane) = session_to_pane(&st.pane, &alive, self.font_size, window, cx) else {
// Nothing came back (an unreachable daemon). Put the entry back so
// the tab is still reopenable once the daemon is up again.
window.push_notification("Could not reopen the tab: no terminal started", cx);
self.closed.push(st);
return;
};
// Leaving the current tab for the reopened one; snapshot its focused
// pane so switching back restores it (same as `activate`).
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,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
// Keep the group it had when closed — the row reappears where
// it lived instead of flashing through Scratch.
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
},
);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
// ── System tray (`ui::tray`) ────────────────────────────────────────────
/// Whether this window hosts the pane with `leaf_id`. The tray's reveal
/// carries a gpui entity id, which is unique app-wide, so this is how a
/// click finds the one window that can act on it.
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)
})
}
/// This window's agent panes, unsorted: brand name, status, and a "where"
/// line (cwd directory name + git branch). Unsorted because the tray is a
/// single icon for the whole app — it concatenates every window's rows and
/// sorts once, most urgent first, so the pane that needs the user tops the
/// menu.
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.leaves() {
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,
// No cwd yet (pane still spawning) — the agent name alone
// still identifies the row.
(None, _) => String::new(),
};
agents.push(crate::ui::tray::AgentRow {
leaf_id: leaf.entity_id().as_u64(),
agent,
status,
detail,
});
}
}
agents
}
/// Apply a tray menu click. Runs on the foreground executor with the
/// window in hand (see `tray::init`'s action pump).
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;
// Tray clicks arrive while another app is frontmost — that's the
// tray's whole premise. `activate_window` alone only orders our
// window front within the app (macOS: `makeKeyAndOrderFront:`); the
// *application* must also be activated or the reveal — and any
// window-modal prompt we show next — stays buried behind the app the
// user clicked from.
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 } => {
// Resolve the leaf against the *live* tree — the menu the user
// clicked may predate a tab close; a vanished pane is a no-op
// (the window still comes forward).
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);
// The reveal must actually show the pane: a sibling leaf
// maximized in this tab would otherwise keep the target
// off-screen while we hand it keyboard focus. The target
// itself staying maximized is fine — it's already the
// visible one.
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);
// Same path as the App menu's "Check for Updates…" — the tray
// used to carry its own copy of this, and was for a while the
// only place in the app offering the check at all.
self.check_for_updates_now(window, cx);
}
// Same as ⌘Q: sessions keep running in the daemon.
TrayAction::Quit => cx.quit(),
TrayAction::QuitStopSessions => self.quit_stop_sessions(window, cx),
}
}
/// Tray "Quit and Stop Daemon": confirm, shut the daemon down (which
/// hangs up every shell — the whole point of picking this over plain
/// quit), then quit. The stop runs off the UI thread; like
/// `--stop-daemon` it can take a beat while children get their grace
/// period.
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// The prompt is window-modal and the click came from the tray with
// another app frontmost — activate the app AND the window, or the
// user never sees the question.
cx.activate(true);
window.activate_window();
let answer = window.prompt(
PromptLevel::Warning,
"Quit and Stop Daemon?",
Some(
"This quits tty7 and stops the background daemon — anything \
still running in your sessions is terminated. Your tabs and \
layout are kept and reopen with fresh shells next launch. \
(Plain Quit keeps sessions running.)",
),
&["Cancel", "Quit and Stop"],
cx,
);
cx.spawn(async move |_this, cx| {
// Index 1 == "Quit and Stop"; Cancel or a dismissed prompt do nothing.
if !matches!(answer.await, Ok(1)) {
return;
}
cx.background_spawn(async { crate::daemon::spawn::stop() })
.await;
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
/// Restart the persistent background daemon: shut the running one down (which
/// stops every live shell) and bring a fresh one up, then rebuild the tabs
/// from the just-saved session so the layout returns with fresh shells.
///
/// A general escape hatch for the otherwise invisible, always-on daemon:
/// picking up a macOS permission granted after it started (Full Disk Access
/// and the like only reach it on a fresh process), recovering if it wedges, or
/// just starting from a clean slate — none of which quitting/reopening the GUI
/// achieves, since that leaves the detached daemon untouched. Guarded by a
/// confirmation because it ends running sessions. The shutdown + respawn runs
/// off the UI thread (the daemon hangs up each child with a short grace, so it
/// can take a beat); the tab rebuild hops back to the main thread, where it has
/// the `Window`.
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let answer = window.prompt(
PromptLevel::Warning,
"Restart Daemon?",
Some(
"This stops every running terminal session — anything still \
running in them will be terminated. Your tabs and layout are kept \
and reopened with fresh shells.",
),
&["Cancel", "Restart"],
cx,
);
cx.spawn(async move |this, cx| {
// Index 1 == "Restart"; Cancel or a dismissed prompt leave everything
// running untouched.
if !matches!(answer.await, Ok(1)) {
return;
}
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
})
.detach();
}
/// The restart itself, past any confirmation — shared by
/// [`restart_daemon`](Self::restart_daemon)'s prompt and the startup
/// version-mismatch prompt
/// ([`prompt_daemon_version_mismatch`](Self::prompt_daemon_version_mismatch)).
fn restart_daemon_confirmed(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
// Persist the current layout + cwds, then tear the live terminals down
// *before* the daemon dies: dropping each `RemoteTerminal` detaches its
// socket, so no reader thread is mid-read when the daemon exits. The
// window briefly shows the empty home page while the daemon restarts.
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;
}
// Shut the old daemon down and spawn a fresh one off the UI thread.
let restarted = cx
.background_spawn(async move { crate::daemon::spawn::restart() })
.await;
// Rebuild from the saved session. The fresh daemon has no live panes,
// so every leaf spawns a new shell in its saved cwd and the tab/split
// layout returns exactly as it was.
let _ = this.update_in(cx, |this, window, cx| {
match &restarted {
Ok(()) => {
let font_size = this.font_size;
// This window's own workspace only — the other windows
// rebuild themselves from theirs.
let saved = WorkspaceStore::all(cx)
.get(this.workspace)
.map(|w| w.session.clone());
let (tabs, active) = tabs_from_session(saved, font_size, window, cx);
this.tabs = tabs;
this.active = active;
}
// The fresh daemon never came up; rebuilding would panic in
// `new_terminal`'s connect `.expect`. Stay on the home page and
// leave a breadcrumb rather than crash — the user can retry.
Err(e) => {
log::error!("restart background service failed, staying on home page: {e}");
}
}
this.focus_active(window, cx);
cx.notify();
});
})
.detach();
}
/// Apply `size` (clamped) as the new global font size across every pane.
/// The element re-measures cell geometry next frame, so the grid reflows
/// automatically once each view is notified.
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.leaves() {
leaf.update(cx, |v, cx| {
v.font_size = px_size;
cx.notify();
});
}
}
// Persist so the zoom level survives a restart.
let cfg = cx.global_mut::<Config>();
cfg.font_size = size;
cfg.save();
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);
}
/// Reset the global font size back to the built-in default. We use the
/// compiled-in default rather than `config.font_size`, because the latter now
/// tracks the live zoom level (persisted on every change), so it no longer
/// serves as a stable reset target.
pub(crate) fn reset_font_size(&mut self, cx: &mut Context<Self>) {
self.set_font_size(Config::default().font_size, cx);
}
/// Apply `mul` (clamped) as the new global line-height multiplier across every
/// pane. Like `set_font_size`, the element re-derives row height next frame, so
/// the grid reflows once each view is notified.
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.leaves() {
leaf.update(cx, |v, cx| {
v.line_height_mul = mul;
cx.notify();
});
}
}
// Persist so the spacing survives a restart.
let cfg = cx.global_mut::<Config>();
cfg.line_height = mul;
cfg.save();
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);
}
/// Reset the line-height multiplier back to the built-in default (see the note
/// on `reset_font_size`: config now tracks the live value, not a reset target).
pub(crate) fn reset_line_height(&mut self, cx: &mut Context<Self>) {
self.set_line_height(Config::default().line_height, cx);
}
/// Switch the active color theme by id, repaint, and persist the choice so
/// it survives a restart. The theme carries its own dark/light brightness.
/// While the system is being followed, the choice lands in the slot for the
/// *current* OS appearance (the theme visibly on screen changes either way).
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, 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();
}
self.after_theme_change(window, cx);
}
/// Set the theme for one follow-system slot explicitly (the Light / Dark
/// cards in Settings). Only visibly changes anything when that slot is the
/// one currently on screen; either way the choice is persisted.
pub(crate) fn set_slot_preset(
&mut self,
dark_slot: bool,
id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
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);
}
/// Turn "sync with system appearance" on/off (the Appearance switch).
/// Turning it off never visibly changes the theme: whatever is on screen
/// is adopted as the manual choice. Turning it on seeds the slot matching
/// the manual theme's own brightness with that theme — so the look only
/// changes when the OS is currently in the *other* mode, where switching
/// to that mode's slot is exactly what the feature promises.
pub(crate) fn set_theme_follow_system(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
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 {
// Resolve while following is still on (the pin is released, so
// this reads the real OS appearance).
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);
// Re-aim an open picker panel at a slot that exists in the new mode —
// after the apply above, so `system_dark` reads the unpinned OS value.
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;
}
}
/// The shared tail of every theme-selection change: repaint, persist, and
/// keep the dependent Settings widgets in step.
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
apply_theme(Some(window), cx);
set_menus(cx);
cx.global::<Config>().save();
// The editor targets the active theme, so its pickers must track a switch.
self.rebuild_theme_editor(window, cx);
// With no global override, the effective opacity follows the theme — keep
// the Appearance slider's thumb on it.
self.sync_window_opacity_slider(window, cx);
cx.notify();
}
/// Show/hide the theme picker panel beside the Appearance page. Clicking
/// the card whose slot the open panel already targets closes it; clicking
/// another card re-aims the open panel at that slot.
pub(crate) fn toggle_theme_panel(
&mut self,
slot: crate::ui::settings::ThemeSlot,
cx: &mut Context<Self>,
) {
if let Some(s) = self.active_settings_mut() {
if s.theme_panel_open && s.theme_panel_slot == slot {
s.theme_panel_open = false;
} else {
s.theme_panel_open = true;
s.theme_panel_slot = slot;
}
cx.notify();
}
}
/// Close the theme picker panel (its `×`).
pub(crate) fn close_theme_panel(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = false;
cx.notify();
}
}
/// Open the user themes folder (`~/.config/tty7/themes`) in the system file
/// browser, creating it first so there's always somewhere to drop a theme.
pub(crate) fn open_themes_folder(&self, cx: &mut Context<Self>) {
if let Some(dir) = crate::ui::presets::themes_dir() {
let _ = std::fs::create_dir_all(&dir);
cx.open_with_system(&dir);
}
}
/// Duplicate the active theme into an editable YAML file, switch to the copy,
/// and open the color editor on it. This is the entry point for customizing a
/// read-only built-in (or an imported iTerm scheme).
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);
// Switches to the copy (applies + persists + rebuilds the editor).
self.set_preset(&new_id, window, cx);
}
Err(e) => log::warn!("failed to duplicate theme: {e}"),
}
}
/// Apply one edit to the active (editable) theme: mutate it, write the
/// theme's file, reload the registry, and repaint live. The shared tail of
/// every in-app theme edit (color pickers, opacity slider, blur switch).
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 Err(e) = crate::ui::presets::write_theme_file(&theme) {
log::warn!("failed to write theme file: {e}");
return;
}
crate::ui::presets::load_registry(cx);
apply_theme(Some(window), cx);
cx.notify();
}
/// Apply one color edit to the active (editable) theme.
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,
);
}
/// The window opacity currently in effect: the global config override when
/// set, else the active theme's own value, else fully opaque.
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));
config.window_opacity.or(theme.opacity).unwrap_or(1.0)
}
/// Set the global window-opacity override from the Appearance slider. Applies
/// to every theme (persisted in the config, not the theme file).
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);
cx.global::<Config>().save();
cx.notify();
}
/// Set the global window-blur override from the Appearance switch.
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);
cx.global::<Config>().save();
cx.notify();
}
/// Clear both window overrides so opacity/blur follow the active theme again
/// (the Appearance section's "Follow theme" action).
pub(crate) fn reset_window_overrides(&mut self, window: &mut Window, cx: &mut Context<Self>) {
{
let config = cx.global_mut::<Config>();
config.window_opacity = None;
config.window_blur = None;
}
apply_theme(Some(window), cx);
cx.global::<Config>().save();
self.sync_window_opacity_slider(window, cx);
cx.notify();
}
/// Snap the Appearance opacity slider's thumb to the value now in effect.
/// Needed whenever that value changes for a reason other than the user
/// dragging it (theme switch, "Follow theme" reset).
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));
}
}
/// Set the active (editable) theme's background image from a native file
/// picker, keeping the existing image opacity (or the schema default).
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,
);
// The editor gains/loses the image-opacity slider with
// the image itself.
this.rebuild_theme_editor(window, cx);
});
}
}
})
.detach();
}
/// Remove the active theme's background image.
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);
}
/// Set the active theme's background-image opacity from the editor slider.
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,
);
}
/// (Re)build the settings tab's color-editor pickers for the current active
/// theme. If no settings tab is open or the active theme isn't an editable
/// file, the editor is cleared. Called after every theme switch / duplicate
/// and when opening settings, so the pickers always reflect (and target) the
/// theme currently on screen.
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();
// (edit target, row label, current 0xRRGGBB value) for each seed color.
let seed_specs: [(ThemeEdit, &str, u32); 5] = [
(
ThemeEdit::Background,
"Background",
theme.background_color(),
),
(ThemeEdit::Foreground, "Foreground", theme.foreground),
(ThemeEdit::Accent, "Accent", theme.accent),
(
ThemeEdit::Cursor,
"Cursor",
theme.caret.unwrap_or(theme.accent),
),
(ThemeEdit::Selection, "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, label, value)| {
(edit, label.to_string(), 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),
format!("Color {i}"),
make(ThemeEdit::Ansi(i), value, &mut subs, cx),
)
})
.collect();
// Background-image opacity slider, present only while the theme has an
// image (choosing/removing one rebuilds the editor). Emits `Change`
// continuously while dragging; each tick writes the theme file and
// repaints, so the mix is live under the thumb.
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,
});
}
}
/// Toggle terminal font ligatures through the generic `font_features`
/// config. On enables the common programming-font features; off restores
/// tty7's terminal-safe default (contextual ligatures disabled).
pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context<Self>) {
let features = on.then(|| {
gpui::FontFeatures(Arc::new(vec![
("calt".to_string(), 1),
("liga".to_string(), 1),
]))
});
self.font_features = features.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let features = features.clone();
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_features = features;
cfg.save();
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.leaves() {
leaf.update(cx, |v, cx| {
v.terminal.apply_user_config(config);
cx.notify();
});
}
}
}
/// Switch the default cursor shape, update each pane's terminal defaults,
/// and repaint. App-requested DECSCUSR shapes still override this at runtime.
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);
}
// ── Config setters (Terminal / Window & Tabs / Cursor settings) ─────────
// Each goes through `update_config` (mutate the global, persist, repaint).
// Effect points read the global live (blink task, `poll_foreground`, link
// gates, `new_tab_insert_at`), so there's nothing to push into the panes —
// except cursor blink, which must un-hide a cursor a prior blink cycle may
// have left dark.
/// Shared tail of every config setter: mutate the global `Config`, persist
/// it, and repaint so the control reflects the new value. Keeping the
/// persist/notify contract here means a future change (e.g. debounced
/// saves) lands in one place.
pub(crate) fn update_config(
&mut self,
cx: &mut Context<Self>,
mutate: impl FnOnce(&mut Config),
) {
let cfg = cx.global_mut::<Config>();
mutate(cfg);
cfg.save();
cx.notify();
}
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_ssh_loopback_forward(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.ssh_loopback_forward = on);
}
/// Global default for native-SSH host-key verification (WS3, FR-S4). A
/// per-profile override still wins where set.
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);
}
/// Global default for confirming before closing a live SSH session (FR-E3).
/// A per-profile `warn_on_close` override still wins where set.
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);
}
/// Refresh the managed (Local/Remote/Dynamic) forwards for `pane_id` (WS4).
pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context<Self>) {
self.loopback_panel.managed = crate::terminal::RemoteTerminal::list_forwards(pane_id);
cx.notify();
}
/// Pick the kind for the add-forward form (native-SSH panes).
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();
}
/// Establish the add-form's managed forward on `pane_id`'s connection, then
/// clear the form. A blank/invalid bind port is ignored; Dynamic forwards need
/// no target.
pub(crate) fn add_managed_forward(
&mut self,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
use crate::daemon::protocol::{SshForwardKind, SshForwardRule};
let kind = self.loopback_panel.mf_kind;
let bind_host = self
.loopback_panel
.mf_bind_host
.read(cx)
.value()
.trim()
.to_string();
let bind_host = if bind_host.is_empty() {
"127.0.0.1".to_string()
} else {
bind_host
};
let Ok(bind_port) = self
.loopback_panel
.mf_bind_port
.read(cx)
.value()
.trim()
.parse::<u16>()
else {
return;
};
let target_host = self
.loopback_panel
.mf_target_host
.read(cx)
.value()
.trim()
.to_string();
let target_port = self
.loopback_panel
.mf_target_port
.read(cx)
.value()
.trim()
.parse::<u16>()
.unwrap_or(0);
// Local/Remote require a target; Dynamic (SOCKS) does not.
if kind != SshForwardKind::Dynamic && (target_host.is_empty() || target_port == 0) {
return;
}
let description = self
.loopback_panel
.mf_description
.read(cx)
.value()
.trim()
.to_string();
let rule = SshForwardRule {
kind,
bind_host,
bind_port,
target_host,
target_port,
description: (!description.is_empty()).then_some(description),
};
// Editing an existing forward = re-establish it: drop the old one first so
// its listener frees the (possibly reused) bind port before the new one binds.
if let Some(old_id) = self.loopback_panel.mf_editing.take() {
let _ = crate::terminal::RemoteTerminal::remove_forward(pane_id, old_id);
}
self.loopback_panel.managed = crate::terminal::RemoteTerminal::add_forward(pane_id, rule);
// The new row *is* the confirmation, so the form folds away rather than
// sitting there re-inviting an add nobody asked for. (Only on the success
// path — every validation failure above returns early with it still open.)
self.loopback_panel.form_pane_id = None;
// Reset the value-carrying fields; keep bind host default.
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();
}
/// Load an existing forward's values into the add form for editing
/// (VSCode-style: change the port/target, Save re-establishes it).
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.mf_editing = Some(forward.id);
// Clicking a row is the only way in, and the form is where the values
// land — so expand it on the row's own pane.
self.loopback_panel.form_pane_id = Some(forward.pane_id);
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));
}
cx.notify();
}
/// Leave edit mode without saving; clear the form back to the add defaults.
pub(crate) fn cancel_managed_forward_edit(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_editing = 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();
}
/// Tear down one managed forward by id (native-SSH panes).
pub(crate) fn remove_managed_forward(
&mut self,
pane_id: u64,
forward_id: u64,
cx: &mut Context<Self>,
) {
self.loopback_panel.managed =
crate::terminal::RemoteTerminal::remove_forward(pane_id, forward_id);
cx.notify();
}
/// `ShowSshForwards` / the palette's "SSH: Port Forwarding": land on the
/// pane's forwards wherever you were. The band lives on the Info tab, so this
/// opens the panel there and expands the add form — the one entry point that
/// works with the panel closed, which is why it exists at all.
///
/// A no-op on anything but a connected native-SSH pane: without a connection
/// there is nothing to forward over, and opening an empty form on a local
/// shell would only be a puzzle.
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((pane_id, _)) = self.active_connected_native_ssh_pane(window, cx) else {
return;
};
self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx);
if self.loopback_panel.form_pane_id != Some(pane_id) {
self.toggle_managed_forward_form(pane_id, window, cx);
}
}
/// The Forwards band's `+`: expand the add form for `pane_id`, or collapse it
/// if it's already this pane's. Collapsing goes through the same reset as
/// Cancel, so a form abandoned mid-edit can't come back still in edit mode.
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.cancel_managed_forward_edit(window, cx);
self.refresh_managed_forwards(pane_id, cx);
}
/// Collapse the add/edit form, clearing it back to the add defaults.
pub(crate) fn close_managed_forward_form(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.form_pane_id = None;
self.cancel_managed_forward_edit(window, cx);
}
/// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/
/// §3.3). The input is parsed as best-effort into a transient profile — a
/// `user@host[:port]` target plus the trivially-mappable flags (`-p`, `-i`,
/// `-l`, `-J`, `-o User=`/`-o Port=`). A line that can't be parsed into a host
/// surfaces a diagnosable inline notice rather than silently shelling out.
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
match parse_ssh_connect_input(input) {
Ok(parsed) => {
// `ssh` semantics: a target naming a `~/.ssh/config` alias
// resolves through it, with typed flags overriding the config's
// values. (After parsing, a port of 22 is indistinguishable
// from "not given", so an explicit `-p 22` can't override a
// config port — the one caveat of this overlay.)
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),
}
}
/// Toggle the startup update check (Settings → About). Takes effect on the
/// next launch — this only persists the preference; it doesn't run or cancel
/// an in-flight check.
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);
}
/// Toggle inactive-pane dimming. Applies on the next render — the pane tree
/// reads the flag from the `Config` global each frame.
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);
// Turning blink off mid-cycle could leave the cursor in its hidden phase;
// force every pane's cursor back on so it doesn't stick invisible.
if !on {
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
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>) {
// Callers pass fixed in-range presets, but clamp anyway so a future caller
// can't smuggle in a degenerate value.
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);
}
/// Set where the tab bar is rendered (Settings → Window & Tabs). Persists the
/// choice; the layout re-derives from the `Config` global on the next render.
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);
}
/// Set how the vertical tab sidebar arranges its rows (Settings → Window &
/// Tabs): grouped per git repo or one flat list. Persists the choice; the
/// sidebar re-derives from the `Config` global on the next render.
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);
}
/// `ToggleTabSidebar`: flip the tab bar between the horizontal title-bar strip
/// (`Top`) and the vertical left sidebar (`Left`), persisting the choice.
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);
}
/// `ToggleLeftPanel` (⌘B): collapse/expand the left rail in place, persisting
/// the choice. In `Top` mode there is no rail to collapse, so this switches to
/// `Left` and shows it — the shortcut always means "give me the sidebar".
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),
// This window's own collapse state — collapsing one window's rail
// must not collapse every other window's. See `sidebar_collapsed`.
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();
}
/// Whether the left rail is actually on screen: `Left` mode, not collapsed,
/// and at least one tab (the home page has no rail). The layout, the title
/// strip and the collapse button all derive from this one predicate.
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);
}
/// Set the "long command" floor (seconds) a foreground command must exceed
/// to be eligible for a completion notification. Read live where the alert
/// is posted, so nothing needs pushing to open panes.
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));
}
/// Switch how the terminal bell is signalled. Read live in each pane's bell
/// handler, so there's nothing to push.
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);
}
/// Toggle session restore. Takes effect on the next launch (this only
/// persists the preference); the current window is untouched.
pub(crate) fn set_restore_session(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.restore_session = on);
}
/// Toggle the system tray icon. The tray's poll loop re-reads the flag
/// every second, so the icon appears/disappears without a restart.
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);
}
/// Toggle the "Close Window?" prompt on the last window. The close handler
/// reads the flag when it fires, so this applies to the very next ⌘W with no
/// restart and nothing to push to open windows.
pub(crate) fn set_confirm_window_close(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.confirm_window_close = on);
}
// ── Input / Mouse setters ───────────────────────────────────────────────
/// Takes effect on the next keystroke — the terminal reads the flag per
/// key event, so nothing needs pushing to open panes.
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);
// Push the new policy to GPUI right away (same call the hot-reload uses).
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);
}
/// Toggle whether mouse events reach full-screen apps. The gates are cached
/// per view, so this pushes the new value into every open pane (like the
/// font setters) in addition to persisting it.
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.leaves() {
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_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);
}
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);
}
/// Name the OS window after its workspace, so ⌘` and Mission Control can
/// tell several tty7 windows apart. Reads the *saved* workspace, which
/// every structural change writes just before focus lands here — a title
/// one beat behind is invisible, and it keeps this off the render path.
///
/// An empty workspace has no subject yet, so it falls back to the app name
/// rather than showing "Untitled".
pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) {
let title = WorkspaceStore::all(cx)
.get(self.workspace)
.filter(|w| !w.session.tabs.is_empty())
.map(|w| w.display_name())
.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) {
// Focus moves after every structural change, which is exactly when the
// window's subject may have changed too.
self.sync_window_title(window, cx);
// While the settings overlay is open it owns focus (so Esc-to-close and
// keybinding capture keep working); tab operations behind it don't steal
// it. `close_settings` refocuses the active terminal on the way out.
if let Some(settings) = self.settings.as_ref() {
window.focus(&settings.focus_handle, cx);
return;
}
let Some(tab) = self.tabs.get(self.active) else {
// No tabs → the home page is showing; keep something focused so
// keystrokes stay on the window's dispatch path (⌘T etc. must still
// reach the root action handlers).
window.focus(&self.home_focus, cx);
return;
};
// A tab showing its diff overlay gives the overlay focus (Esc-to-close
// must keep working when switching back to it); `close_diff_overlay`
// re-runs this after clearing the slot to land on the terminal.
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.read(cx).focus_handle.clone();
window.focus(&handle, cx);
}
}
/// Snapshot which pane currently holds focus in the active tab into that
/// tab's `last_focused`, so `focus_active` can restore it when we come back.
/// Call this before any transition that moves focus off the active tab
/// (switching tabs, opening a focus-stealing overlay).
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());
}
}
}
fn focus_leaf(&self, leaf: &Entity<TerminalView>, window: &mut Window, cx: &mut App) {
let handle = leaf.read(cx).focus_handle.clone();
window.focus(&handle, cx);
}
/// Ask every pane in the window to re-probe its git status. Called when the
/// window regains focus: the sidebar shows a git line for *every* tab, not
/// just the active one, so refreshing only the focused pane would leave the
/// rest of the list stale — which is exactly the list the user is scanning
/// right after switching back.
///
/// Panes sharing a cwd fold into one probe in the shared cache, and the
/// throttle there counts per repo rather than per cwd, so once the cache
/// knows where each pane lives the cost of a window with many panes is
/// bounded by the number of distinct repos — not by the number of
/// subdirectories they happen to sit in, which would be the same full-repo
/// `git diff` asked several times over.
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) {
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
}
}
/// Where a freshly opened tab should be inserted, per `new_tab_position`:
/// right after the active tab, or appended at the end. Clamped to the tab
/// count so the zero-tab home state (active 0, no tabs) inserts at 0.
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);
}
/// Open a new tab running `shell` — a pick from the "+" dropdown — or the
/// default shell when `None` (the plain "+" click / Cmd+T path).
pub(crate) fn new_tab_with_shell(
&mut self,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Inherit the cwd of the active tab's focused terminal so the new tab
// opens in the same directory the user is currently working in. Local
// cwds only: the new tab is a local shell, and an inherited cwd wins
// over every fallback in `pane::initial_working_directory`, so a remote
// path would be handed straight to the spawn as a working directory.
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
});
let tab = match new_terminal(self.font_size, cwd, None, shell, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("new tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
return;
}
};
// Leaving the current tab for the new one; snapshot its focused pane
// so switching back restores it (same as `activate`).
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(tab)));
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Open a new tab running a native (russh) SSH session for the resolved
/// `spec` (PRD FR-C1). The caller (`ui::ssh_connect`) has already pulled any
/// keychain secrets into `spec`. Mirrors `new_tab_with_shell` but for the
/// native backend.
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(format!("SSH connection failed: {e}"), cx);
return;
}
};
// Leaving the current tab for the new one; snapshot its focused pane
// so switching back restores it (same as `activate`).
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(view)));
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Respawn a native SSH pane **in place** (same tab / split slot), replacing a
/// dead pane's view with a fresh native connection for `spec` (PRD FR-E4). The
/// daemon re-establishes the profile's preconfigured forwards on connect.
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(format!("SSH reconnect failed: {e}"), cx);
return;
}
};
// Swap the fresh leaf into the dead one's position across every tab.
for tab in &mut self.tabs {
if tab.pane.replace_leaf(dead, fresh.clone()) {
break;
}
}
self.maximized = None;
self.focus_leaf(&fresh, window, cx);
self.save_session(cx);
cx.notify();
}
/// Split the focused pane in the active tab, focusing the new terminal.
pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) {
// Capture the target leaf BEFORE creating the new terminal: constructing
// a TerminalView focuses it, which would otherwise make us lose track of
// which pane to split (nested splits would always hit the first leaf).
let Some(target) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
// The new pane inherits the cwd — and the shell, when the pane being
// split was opened with an explicit pick (a WSL/fish tab splits into
// more WSL/fish, not back to the default). Local cwds only: the
// native-SSH branch below has the daemon discard it regardless, and the
// local branch would otherwise spawn against a remote path.
let cwd = target.read(cx).local_cwd();
// Splitting a native-SSH pane opens another SSH pane on the same
// connection rather than dropping back to a local shell. Re-resolve the
// persisted (secret-free) spec from its saved profile so keychain
// secrets are re-applied, mirroring the reconnect path.
let ssh_spec = target.read(cx).ssh_spec();
let new = if let Some(spec) = ssh_spec {
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
match new_terminal_native(self.font_size, cwd, resolved, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH split spawn failed: {e}");
window.push_notification(format!("SSH connection failed: {e}"), cx);
return;
}
}
} else {
let shell = target.read(cx).shell_spec();
match new_terminal(self.font_size, cwd, None, shell, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("split spawn failed: {e}");
window.push_notification(format!("Could not split the pane: {e}"), cx);
return;
}
}
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.split_leaf(&target, axis, new.clone()) {
self.maximized = None;
self.focus_leaf(&new, window, cx);
self.save_session(cx);
cx.notify();
}
}
}
/// Close the focused pane. If it was the tab's only pane, close the tab.
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// FR-E3: if the focused pane is a live SSH session flagged warn-on-close,
// raise the in-pane confirm sheet instead of closing outright.
if self.ssh_close_confirm.is_none() && self.focused_pane_is_warn_ssh(window, cx) {
self.ssh_close_confirm = Some(SshCloseKind::Pane);
cx.notify();
return;
}
self.ssh_close_confirm = None;
self.maximized = None;
// Capture the focused leaf before closing: if a split collapses, that
// leaf is destroyed with no reopen path, so we kill its daemon pane. Owned
// clones from `leaves()` end the borrow before the `&mut` close below.
let focused = self.tabs.get(self.active).and_then(|tab| {
tab.pane
.leaves()
.into_iter()
.find(|l| l.read(cx).focus_handle.contains_focused(window, cx))
});
let outcome = match self.tabs.get_mut(self.active) {
Some(tab) => tab.pane.close_focused(window, cx),
None => return,
};
match outcome {
CloseOutcome::RemoveSelf => {
// The focused leaf *is* the tab's only pane: close the tab, which
// kills its panes itself.
self.close_tab(self.active, window, cx);
}
CloseOutcome::NotFound => {
// No terminal leaf in the active tab holds focus (focus is in the
// rename input / settings / drifted). Only fall back to closing the
// tab when it's a single pane — never silently destroy a multi-pane
// split whose target the user can't see.
let single = self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
if single {
self.close_tab(self.active, window, cx);
}
}
CloseOutcome::Collapsed => {
if let Some(leaf) = &focused {
crate::terminal::RemoteTerminal::kill_pane(leaf.read(cx).pane_id);
}
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
}
}
/// Close the pane whose shell just exited on its own (`ChildExited` from
/// the view — `exit`, Ctrl-D, a crashed shell): collapse its split, or
/// close its tab when it was the only pane. Unlike `close_pane` this
/// targets the *emitting* leaf, not the focused one — the exit can happen
/// in a background tab. The daemon pane is killed even though its child is
/// already dead: the daemon still lists it for reattach, and killing is
/// what drops it from the session.
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; // already closed (e.g. by the user racing the exit)
};
// A native-SSH pane lingers instead of closing (PRD FR-C2/E4): a failed
// connect's diagnostic must stay readable, and a dropped session's pane
// is the anchor for the in-pane reconnect (`restart_ssh_session`) —
// auto-close would make both unreachable. Only local shells fall through
// to the close below.
if view.read(cx).ssh_disconnected() {
cx.notify();
return;
}
match self.tabs[index].pane.close_leaf(&view) {
// The exited pane was the tab's only leaf: close the whole tab
// (which snapshots it for reopen and kills its daemon panes).
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
// Unreachable — containment was just checked — but never close a
// tab we failed to locate the leaf in.
CloseOutcome::NotFound => {}
CloseOutcome::Collapsed => {
crate::terminal::RemoteTerminal::kill_pane(view.read(cx).pane_id);
if index == self.active {
self.maximized = None;
self.focus_active(window, cx);
}
self.save_session(cx);
cx.notify();
}
}
}
/// Cycle focus among the panes of the active tab.
fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
// `leaves()` returns owned clones, so the immutable borrow of `self.tabs`
// ends here — letting us mutate `self.maximized` just below.
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.read(cx).focus_handle.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();
}
/// Move focus to the pane adjacent to the focused one in `dir` (tmux
/// directional focus). A no-op when there's no neighbor that way.
fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self
.tabs
.get(self.active)
.and_then(|tab| tab.pane.neighbor_in_dir(dir, window, cx))
else {
return;
};
self.maximized = None;
self.focus_leaf(&target, window, cx);
cx.notify();
}
/// Grow/shrink the focused pane along `dir` by one step, adjusting its
/// nearest matching-axis split. Persists the new layout. A no-op when no
/// split matches (e.g. a single-pane tab, or no divider on that axis).
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();
}
}
/// Swap the focused pane with its next / previous sibling in leaf order
/// (tmux `prefix }` / `prefix {`). The terminals trade tree positions;
/// focus rides along with the moved terminal. Needs at least two panes.
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();
}
}
}
/// Switch to the next / previous tab, wrapping around (tmux `prefix n/p`).
/// A no-op with fewer than two tabs.
fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let n = self.tabs.len();
if n < 2 {
return;
}
let next = if forward {
(self.active + 1) % n
} else {
(self.active + n - 1) % n
};
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 {
// Remember the pane we're leaving focused so returning to this tab
// restores it instead of jumping to the first leaf.
self.remember_active_pane(window, cx);
self.maximized = None;
self.active = index;
// The incoming tab may have a diff overlay that went stale while
// hidden (its repo changed underneath); re-probe if the status
// cache disagrees with the shown snapshot.
self.maybe_refresh_diff_overlay(cx);
// In sidebar mode, pull the newly active row into view (a no-op when
// the strip is horizontal — the handle tracks no painted list then).
self.sidebar_scroll.scroll_to_item(index);
if self.code_panel_visible() {
// The incoming tab has its own panel open: refresh its roots
// (pane cwds may have changed) and keep focus on the panel.
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();
}
}
/// Toggle maximize on the active tab's focused pane (Cmd+Shift+Enter). When a
/// pane is maximized the tab renders only that leaf full-window; toggling again
/// (or any structural change) restores the split layout. A no-op when the
/// active tab has a single pane (nothing to maximize).
fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.maximized.is_some() {
self.maximized = None;
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_or_first(window, cx);
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>) {
// Closing the last tab is allowed: zero tabs is the home page (see
// `ui::home`), and `focus_active`/`render` both handle it.
if index >= self.tabs.len() {
return;
}
// FR-E3: confirm before closing a tab that holds a live warn-on-close SSH
// session (unless this call is the confirmation itself).
let already_confirming = self.ssh_close_confirm == Some(SshCloseKind::Tab(index));
if !already_confirming && self.tab_has_warn_ssh(index, cx) {
self.ssh_close_confirm = Some(SshCloseKind::Tab(index));
cx.notify();
return;
}
self.ssh_close_confirm = None;
self.maximized = None;
// A rename in progress stores a fixed tab index; removing a tab shifts
// indices and would let the pending edit commit onto the wrong tab. Drop it.
self.renaming = None;
// Capture the tab's cwd *before* its panes are killed (the daemon can't
// report it afterwards): if it sat in a tty7-managed worktree, the
// cleanup offer below needs it.
let worktree_cwd = self.tab_local_cwd(index, window, cx);
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back.
let snapshot = tab_to_session(&self.tabs[index], cx);
self.closed.push(snapshot);
if self.closed.len() > MAX_CLOSED_TABS {
self.closed.remove(0);
}
// Explicitly closing a tab kills its daemon panes (matching the old
// in-process behavior: closing ends the shells). This is distinct from
// *quitting* the app, where panes are detached and kept alive so the
// next launch can re-attach. Reopen-closed-tab then spawns fresh in the
// saved cwd, just like before the daemon split.
for leaf in self.tabs[index].pane.leaves() {
crate::terminal::RemoteTerminal::kill_pane(leaf.read(cx).pane_id);
}
self.tabs.remove(index);
if self.tabs.is_empty() {
// Home page: keep `active` at a stable 0 (every access goes through
// `tabs.get`, which yields None until a tab exists again).
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();
// The tab is gone; if it lived in a tty7-managed worktree, offer to
// clean the checkout up rather than letting them pile up silently.
self.offer_worktree_cleanup(worktree_cwd, cx);
}
/// After closing a tab that sat in a tty7-managed worktree (see
/// [`crate::core::worktree::managed`]), offer to remove the checkout: a
/// clean worktree gets a plain keep/remove prompt; one with uncommitted
/// changes defaults to keeping and makes discarding explicit. Removal also
/// deletes the branch when it carries no unmerged commits (`branch -d`).
/// No offer while any surviving pane still has its cwd inside the checkout
/// (new tabs inherit the current cwd, so shared worktrees are common) —
/// removal would yank the directory out from under a live shell.
/// Detection, the dirty probe, and removal all run off the UI thread.
fn offer_worktree_cleanup(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
let Some(cwd) = cwd else { return };
// Every leaf of every surviving tab, not just focused panes — a shell
// tucked away in a split occupies the worktree all the same.
// Local paths only: this list is what stops a worktree being removed
// out from under a live shell, and a remote cwd can neither occupy a
// local worktree nor be compared against one meaningfully.
let open_cwds: Vec<std::path::PathBuf> = self
.tabs
.iter()
.flat_map(|tab| tab.pane.leaves())
.filter_map(|leaf| leaf.read(cx).local_cwd())
.collect();
cx.spawn(async move |this, cx| {
let Some(wt) = cx
.background_spawn(async move {
crate::core::worktree::managed(&cwd)
.filter(|wt| !crate::core::worktree::occupied(&wt.path, &open_cwds))
})
.await
else {
return;
};
let detail = if wt.dirty {
format!(
"The closed tab's worktree at {} has uncommitted changes.",
wt.path.display()
)
} else {
format!(
"The closed tab's worktree at {} is clean.",
wt.path.display()
)
};
let title = format!("Remove worktree \"{}\"?", wt.branch);
let level = if wt.dirty {
PromptLevel::Warning
} else {
PromptLevel::Info
};
let remove_label = if wt.dirty {
"Discard Changes & Remove"
} else {
"Remove Worktree"
};
let Ok(answer) = this.update_in(cx, |_, window, cx| {
window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx)
}) else {
return;
};
if !matches!(answer.await, Ok(1)) {
return;
}
let force = wt.dirty;
let branch = wt.branch.clone();
let result = cx
.background_spawn(async move { crate::core::worktree::remove(&wt, force) })
.await;
let _ = this.update_in(cx, |_, window, cx| match result {
Ok(()) => window.push_notification(format!("Removed worktree \"{branch}\""), cx),
Err(e) => window.push_notification(format!("Worktree removal failed: {e}"), cx),
});
})
.detach();
}
/// Close every tab except `index` ("Close Other Tabs"). Iterates from the
/// end so removals never shift an index still to visit. Tabs holding a live
/// warn-on-close SSH session are skipped outright — the per-tab confirm
/// sheet is keyed by index, which a bulk close would immediately
/// invalidate — so they simply survive the sweep.
pub(crate) fn close_other_tabs(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if index >= self.tabs.len() {
return;
}
for i in (0..self.tabs.len()).rev() {
if i == index || self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab(i, window, cx);
}
}
/// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs
/// Below" in the sidebar). Same end-first iteration and warn-SSH skip as
/// [`close_other_tabs`](Self::close_other_tabs).
pub(crate) fn close_tabs_right_of(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
for i in ((index + 1)..self.tabs.len()).rev() {
if self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab(i, window, cx);
}
}
/// "Mark as Unread" (tab context menu): re-flag every finished (`Done`)
/// agent turn in the tab as unread, so the avatar's green dot swells back
/// into its count badge until the user next looks at those panes. The
/// active tab's focus target is told the dismissed menu is about to hand
/// focus back to it, so that focus-in doesn't immediately re-read the mark
/// (see `TerminalView::mark_agent_result_unread`).
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.leaves() {
let refocus_incoming = refocus.as_ref() == Some(&leaf);
leaf.update(cx, |view, cx| {
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
view.mark_agent_result_unread(refocus_incoming);
cx.notify();
}
});
}
cx.notify();
}
/// The cwd of the tab's label-driving terminal (focused leaf, else first) —
/// what the tab context menu's "Copy Working Directory" copies and "New
/// Worktree Tab" derives the repo from.
pub(crate) fn tab_cwd(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
}
/// Copy the active tab's working directory to the clipboard — the
/// `CopyWorkingDirectory` action behind the File menu, the palette, and the
/// tab context menu's row of the same name. A no-op when the pane has yet to
/// report a cwd, which is also when the context-menu row renders disabled.
pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context<Self>) {
if let Some(cwd) = self.tab_cwd(self.active, window, cx) {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(cwd.display().to_string()));
}
}
/// An explicit "check now", from the App menu or the tray. Forced, so it
/// works even with the startup check turned off — "I asked" outranks "don't
/// ask on my behalf" — and it opens About, where the result lands.
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);
}
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
/// for the worktree operations, which shell out to a local `git`. "Copy
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
/// pane's remote path is exactly what the user wants there.
fn tab_local_cwd(&self, index: usize, window: &Window, cx: &App) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
}
/// "New Worktree Tab": probe the repository containing the tab's cwd for
/// defaults (a fresh generated name, the current branch as start point) on
/// the background executor, then open the confirmation sheet
/// (`ui::worktree_prompt`) where name/branch/base can be edited before
/// anything is created. Failure to probe lands as a notification.
pub(crate) fn new_worktree_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
window.push_notification("This tab has no working directory yet", cx);
return;
};
cx.spawn(async move |this, cx| {
let probe_cwd = cwd.clone();
let result = cx
.background_spawn(async move { crate::core::worktree::defaults(&probe_cwd) })
.await;
let _ = this.update_in(cx, |this, window, cx| match result {
Ok(defaults) => this.open_worktree_prompt(cwd, defaults, window, cx),
Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx),
});
})
.detach();
}
/// Open the tab for a just-created worktree: a default-shell terminal in
/// the worktree directory, with the tab pre-named after its branch so a
/// strip of parallel worktrees stays tellable-apart. Mirrors
/// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point).
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.font_size, Some(wt.path), None, None, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("worktree tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), 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();
}
/// Rearrange the whole tab vector into `order` (old indices in their new
/// order) — the single path by which a drag-reorder lands, used by the
/// sidebar where a single visual move can imply a larger permutation
/// (relocating a tab inside its group without disturbing the group order,
/// or moving an entire group). Keeps the same tab active and re-persists.
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;
}
// Reordering shifts indices: a rename pending on a fixed one would
// commit onto the wrong tab.
self.renaming = None;
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();
}
/// Begin an inline rename of the tab at `index`: spawn a focused text input
/// pre-filled with the current label, committing on Enter or blur.
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 input = cx.new(|cx| InputState::new(window, cx).default_value(current));
input.update(cx, |state, cx| state.focus(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 {
index,
input,
_subs: subs,
});
cx.notify();
}
/// The set of daemon panes currently alive, cached for [`ALIVE_TTL`].
pub(crate) fn alive_panes_cached(&self) -> HashSet<u64> {
const ALIVE_TTL: std::time::Duration = std::time::Duration::from_millis(2000);
let mut slot = self.alive_cache.borrow_mut();
if let Some((taken, panes)) = slot.as_ref()
&& taken.elapsed() < ALIVE_TTL
{
return panes.clone();
}
let panes = alive_panes();
*slot = Some((std::time::Instant::now(), panes.clone()));
panes
}
/// Turn the title-bar workspace chip into a text field, seeded with the
/// current name. Committing on Enter or blur mirrors the tab rename.
pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let current = WorkspaceStore::all(cx)
.get(self.workspace)
.map(|w| w.display_name())
.unwrap_or_default();
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
input.update(cx, |state, cx| state.focus(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();
}
/// Commit the workspace rename. An empty value clears the custom name, so
/// the chip falls back to the derived repo name — the same "clear to
/// revert" contract the tab rename has.
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;
WorkspaceStore::rename(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();
}
/// Commit the in-progress rename: a non-empty value becomes the tab's custom
/// name; an empty value clears it (reverting to the title-derived label).
/// Taking `renaming` first makes the focus change below re-entrancy-safe (the
/// input's resulting Blur finds no active rename and returns).
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().trim().to_string();
if let Some(tab) = self.tabs.get_mut(renaming.index) {
tab.name = if value.is_empty() { None } else { Some(value) };
}
self.save_session(cx);
crate::ui::windows::refresh_menu(cx);
self.focus_active(window, cx);
cx.notify();
}
// ----- Command palette -------------------------------------------------
/// Build the full command catalog: the static commands plus one
/// "Switch to Tab: …" entry per open tab (label matches the tab strip).
fn palette_commands(&self, cx: &App) -> Vec<Command> {
// This window's own chrome state, not the config's copy of it — see
// `ChromeState`.
let mut commands = Command::base_commands(
cx,
ChromeState {
rail_collapsed: self.sidebar_collapsed,
right_panel_visible: self.right_panel_visible,
},
);
// Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row
// connects (natively) on Enter and edits on ⌘⏎ / →.
let cfg = cx.global::<Config>();
let now = crate::core::config::unix_now();
let mut profiles: Vec<&crate::core::ssh_profile::SshProfile> =
cfg.ssh_profiles.iter().collect();
profiles.sort_by(|a, b| {
let score = |p: &crate::core::ssh_profile::SshProfile| {
cfg.ssh_profile_frecency
.get(&p.id)
.map(|u| u.score(now))
.unwrap_or(0.0)
};
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for p in profiles {
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(
format!("SSH: {title}"),
CommandKind::ConnectSavedProfile(p.id),
)
.with_subtitle(subtitle)
.in_group(CommandGroup::Ssh),
);
}
// Saved profiles are the palette's *only* SSH listing: `~/.ssh/config`
// hosts appear here after Settings → SSH → "Import from ~/.ssh/config"
// turns them into profiles, never as a parallel live-discovered source
// (two lists of the same hosts with different behaviors confused more
// than it helped). Typing an alias into "SSH: Add Connection…" still
// resolves it against ssh_config on the spot.
for (i, tab) in self.tabs.iter().enumerate() {
// Skip the active tab — "switch to the tab you're already on" is a
// no-op that only pads the list.
if i == self.active {
continue;
}
let label = self.tab_label(tab, i, None, cx);
commands.push(
Command::new(
format!("Switch to Tab: {label}"),
CommandKind::ActivateTab(i),
)
.in_group(CommandGroup::TabsPanes),
);
}
commands
}
/// Open the palette if closed, or close it if already open (Cmd+P toggles).
fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.palette.is_some() {
self.close_palette(window, cx);
return;
}
// Build the catalog and hand it to a fresh palette view; it owns the
// search input, filtering and keyboard nav, and emits a `PaletteEvent`
// when the user confirms or dismisses.
let commands = self.palette_commands(cx);
let view = cx.new(|cx| PaletteView::new(commands, window, cx));
self.palette_sub = Some(cx.subscribe_in(&view, window, Self::on_palette_event));
self.palette = Some(view);
cx.notify();
}
/// Run the confirmed command (or just close on dismiss) for the open palette.
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();
self.close_palette(window, cx);
self.run_command(kind, window, cx);
}
PaletteEvent::Dismiss => self.close_palette(window, cx),
}
}
/// Close the palette and hand focus back to the active terminal.
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.palette = None;
self.palette_sub = None;
self.focus_active(window, cx);
cx.notify();
}
/// The focused terminal of the active tab, for palette commands that act on
/// the pane rather than the shell. The palette has already closed by the
/// time these run, so focus is back where the user left it.
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))
}
/// Record that a palette command was run, for the palette's Recent band.
/// Only commands with a stable id are tracked (see `CommandKind::id`).
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();
});
}
/// Run a palette command by dispatching to the matching tab/pane operation.
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 => crate::ui::windows::open(cx, None),
// The opener never reaches here (the palette swaps its own list),
// but the match must stay exhaustive.
OpenWorkspacePicker => {}
SwitchToWorkspace(id) => self.reveal_workspace(id, window, cx),
StopWorkspace => self.stop_workspace(self.workspace, window, cx),
DeleteWorkspace => self.delete_workspace(self.workspace, 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),
NextTab => self.cycle_tab(true, window, cx),
PrevTab => self.cycle_tab(false, window, cx),
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => window.toggle_fullscreen(),
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),
// Pane-scoped commands act on the terminal the closing palette just
// handed focus back to.
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) {
// `false`: a menu/palette copy leaves the highlight up. Only
// the dual-purpose ⌃C chord has to consume the selection.
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),
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),
Quit => cx.quit(),
RestartDaemon => self.restart_daemon(window, cx),
ToggleSftp => self.toggle_sftp(window, cx),
ShowSshForwards => self.show_ssh_forwards(window, cx),
ToggleCodePanel => self.toggle_code_panel(window, 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),
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),
// Handled inside `PaletteView` (opens a sub-list); these never emit a
// `Confirm` for this variant, so they never reach here.
OpenThemePicker | OpenSshConnectInput => {}
ActivateTab(i) => self.activate(i, window, cx),
}
}
// ----- Agent context feed (palette: "Agent: …") -------------------------
/// The pane the agent-feed commands deliver to: the first leaf running a
/// recognized coding agent, preferring the active tab, then any tab. `None`
/// when no agent runs anywhere.
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.leaves().into_iter().find(runs_agent)
{
return Some(leaf);
}
self.tabs
.iter()
.enumerate()
.filter(|(i, _)| *i != self.active)
.flat_map(|(_, t)| t.pane.leaves())
.find(runs_agent)
}
/// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to
/// the front so the user sees the turn start. Toasts when no agent runs.
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"),
"No running coding agent found — start one (claude, codex, …) in a pane first.",
);
return;
};
target.read(cx).send_agent_prompt(prompt);
if let Some(i) = self
.tabs
.iter()
.position(|t| t.pane.leaves().contains(&target))
{
self.activate(i, window, cx);
}
}
/// "Agent: Send Selection" — the focused pane's selection, phrased as a
/// prompt, into the running agent's pane (the context-feed idea).
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"),
"Nothing selected — select some terminal output first.",
);
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);
}
}
/// "Agent: Send Git Diff for Review" — the focused pane's repo diff
/// (unstaged + staged), phrased as a review prompt, into the agent's pane.
fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let cwd = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
// Local `git` shell-out, so a remote pane's cwd is not usable —
// it reports "no known directory" rather than silently diffing
// whatever the path collides with locally.
.and_then(|view| view.read(cx).local_cwd());
let Some(cwd) = cwd else {
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
return;
};
// Unstaged + staged, concatenated — "everything not yet committed",
// which is what a review pass wants. Both invocations are quick; the
// prompt builder caps runaway diffs.
let run = |args: &[&str]| {
let mut cmd = std::process::Command::new("git");
cmd.args(args).current_dir(&cwd);
crate::core::proc::hide_console(&mut cmd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
};
let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
let cwd_s = cwd.to_string_lossy().into_owned();
match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
Some(prompt) => self.deliver_agent_prompt(&prompt, window, cx),
None => crate::terminal::notify_desktop(
Some("tty7"),
&format!("No uncommitted changes in {cwd_s} (or not a git repository)."),
),
}
}
// ----- Settings tab (Cmd+,) -------------------------------------------
/// Toggle the settings overlay (Cmd+,). If it's already open, close it;
/// otherwise assemble its widget state (each control pre-filled from config,
/// with its subscriptions pushed onto `subs`) and focus the page.
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.is_some() {
self.close_settings(window, cx);
return;
}
// Settings is about to steal focus; snapshot the active pane so closing
// it lands back on the same terminal rather than the tab's first leaf.
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) =
self.build_font_selects(&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 scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
// Live filter for the theme picker panel; each keystroke re-renders the
// (already-cheap) list so results narrow as you type.
let theme_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search themes…"));
subs.push(
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
// Live filter for the nav-header settings search; each keystroke re-renders
// the (cheap) nav rail so the result list narrows as you type.
let settings_search =
cx.new(|cx| InputState::new(window, cx).placeholder("Search settings…"));
subs.push(
cx.subscribe_in(&settings_search, window, |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
this.autoselect_settings_search(cx);
cx.notify();
}
}),
);
self.settings = Some(SettingsState {
focus_handle: focus_handle.clone(),
section: SettingsSection::Appearance,
search: settings_search,
font_select,
font_bold_select,
font_italic_select,
shell_program_input,
shell_args_input,
wd_path_input,
link_file_command_input,
scroll_slider,
window_opacity_slider,
theme_editor: None,
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,
agent_hooks_states: Self::agent_hooks_snapshot(),
agent_hooks_note: None,
_subs: subs,
});
// Land the caret in the search box so Settings opens ready to type/filter
// (a blinking cursor), rather than on the inert page root. Escape still
// closes — the root's key handler is an ancestor of the focused input.
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),
}
// Build the color editor if we opened straight onto an editable theme.
self.rebuild_theme_editor(window, cx);
cx.notify();
}
/// Primary / bold / italic font-family pickers, seeded from config.
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>>>,
) {
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();
// Every font the OS reports is selectable — we don't get to decide
// that for the user. The picker's dropdown just caps its own height
// (see `menu_max_h` in settings.rs) so browsing the full list doesn't
// dump it all on screen at once; search still reaches everything.
let mut font_names = cx.text_system().all_font_names();
if !font_names.contains(&family) {
font_names.push(family.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)
});
// Bold / italic pickers share the font list but prepend a "Default" row
// (the `FONT_DEFAULT_LABEL` sentinel) so the user can clear a distinct
// face back to synthesized emphasis.
let build_alt_font_select = |value: &Option<String>,
names: &[String],
window: &mut Window,
cx: &mut Context<Self>| {
let mut rows = Vec::with_capacity(names.len() + 1);
rows.push(crate::ui::settings::FONT_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 font_bold_select = build_alt_font_select(&font_bold, &font_names, window, cx);
let font_italic_select = build_alt_font_select(&font_italic, &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);
}
},
));
(font_select, font_bold_select, font_italic_select)
}
/// Shell program/args and working-directory inputs, committing on Enter/blur.
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>();
// Pre-fill the shell inputs from config; an unset `shell` leaves them
// empty so the placeholders advertise the platform default.
let (shell_program, shell_args) = match &cfg.shell {
Some(s) => (s.program.clone(), s.args.join(" ")),
None => (String::new(), String::new()),
};
let wd_path = cfg.working_directory.path.clone();
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
"login shell"
};
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("none")
.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)
}
/// File-open command template input (Links section), committing on Enter/blur.
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("open in default app")
.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
}
/// Persist the file-open command template from the Links settings input. An
/// empty value clears the override (falls back to the built-in open).
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; // no change — avoid a redundant disk write on every Blur
}
cfg.link_file_command = command;
cfg.save();
cx.notify();
}
/// Window-opacity slider for the Appearance page (20%100%). Emits `Change`
/// continuously as the user drags; each tick sets the global override and
/// repaints, so the translucency is live under the thumb.
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| {
if let SliderEvent::Change(v) = ev {
this.set_window_opacity(v.start(), window, cx);
}
}),
);
slider
}
/// Mouse-scroll multiplier slider (0.5×–5×). Emits `Change` continuously as
/// the user drags; each writes + persists the multiplier.
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| {
if let SliderEvent::Change(v) = ev {
this.set_mouse_scroll_multiplier(v.start(), cx);
}
},
));
scroll_slider
}
/// Close the settings overlay (Esc inside the panel, or Cmd+, again),
/// dropping its widget state and returning focus to the active terminal.
pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.take().is_some() {
self.focus_active(window, cx);
cx.notify();
}
}
/// Open Settings focused on `section`, opening the overlay if it's closed.
/// Unlike `toggle_settings`, this never closes an already-open Settings — the
/// entry points that jump to a specific section (e.g. SSH profiles) use it.
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.select_settings_section(section, cx);
}
/// Open Settings → SSH with `id`'s profile loaded into the inline edit form
/// (the ⌘⏎ / Edit affordance on a saved profile).
pub(crate) fn open_ssh_profile_in_settings(
&mut self,
id: uuid::Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings_section(SettingsSection::Ssh, window, cx);
if let Some(profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.cloned()
{
self.ssh_form_load(&profile, window, cx);
}
}
/// Open Settings → SSH with a new profile seeded from a QuickConnect target
/// ("save as profile"), ready to edit and save.
pub(crate) fn open_ssh_profile_new_from_target(
&mut self,
target: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings_section(SettingsSection::Ssh, window, cx);
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.ssh_form_load(&profile, window, cx);
}
/// Apply the picked font family live to every terminal and persist it.
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.leaves() {
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;
cfg.save();
cx.notify();
}
/// Apply a distinct bold or italic face (or clear it back to synthesized
/// emphasis when the `FONT_DEFAULT_LABEL` sentinel is picked) live to every
/// pane, and persist it. `bold == true` targets the bold face, else italic.
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.leaves() {
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;
}
cfg.save();
cx.notify();
}
/// Re-apply hot-reloaded config to every live pane. Wired to
/// `observe_global::<Config>`, so an external edit to `config.json` — picked
/// up by the watcher in `main.rs`, which swaps the `Config` global — flows to
/// the on-screen terminals without a restart. This complements `apply_theme`
/// (which already handles the color side) by covering the font knobs that
/// live on `Tty7App`/the panes: size, line height, and family.
///
/// Each field is diffed against the currently-applied value and skipped when
/// unchanged. That keeps this a no-op for the much more frequent case where
/// *our own* code mutated the global (every font setter and `set_preset`
/// writes it), and — because we never write the global or `save()` from here
/// — closes the save → watch → reload loop that would otherwise oscillate.
fn reload_from_config(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Re-apply the theme with the window in hand: the watcher task calls
// `apply_theme(None)` (colors/palette), but the window-bound effects — the
// Transparent↔Blurred background flip and traffic-light re-pinning — only
// happen here. Also keeps the Appearance opacity slider's thumb on a value
// that was hand-edited in `config.json` or the theme file.
apply_theme(Some(window), cx);
self.sync_window_opacity_slider(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_features) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_features.clone(),
)
};
// Keep the runtime sidebar width in step with the config (an external
// edit to `config.json`, or our own drag-end persist which re-fires this).
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
self.right_panel_width
.set(cx.global::<Config>().right_panel_width);
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.leaves() {
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.leaves() {
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.leaves() {
let family = font_family.clone();
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
}
}
}
if font_features != self.font_features {
self.font_features = font_features.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
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.leaves() {
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.leaves() {
let italic = italic.clone();
leaf.update(cx, |v, cx| v.set_font_family_italic(italic, cx));
}
}
}
// Mouse-reporting is cached per view (the gates run without a `cx`), so a
// hot-reload must push it into every open pane. Diffed per leaf so an
// unrelated config edit doesn't churn panes that already agree.
let report_mouse = cx.global::<Config>().mouse_reporting;
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
if v.report_mouse != report_mouse {
v.report_mouse = report_mouse;
cx.notify();
}
});
}
}
cx.notify();
}
/// Persist the shell program + args from the settings inputs. An empty
/// program clears the override (`shell: None`), so the daemon falls back to
/// the platform default. Only newly spawned panes pick this up — the daemon
/// reads `config.json` fresh on each PTY spawn — so running shells are
/// untouched. There's nothing to apply live here; we just save.
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();
let args: Vec<String> = settings
.shell_args_input
.read(cx)
.value()
.split_whitespace()
.map(str::to_string)
.collect();
let shell = if program.is_empty() {
None
} else {
Some(ShellConfig { program, args })
};
let cfg = cx.global_mut::<Config>();
if cfg.shell == shell {
return; // no change — avoid a redundant disk write on every Blur
}
cfg.shell = shell;
cfg.save();
cx.notify();
}
/// Change the working-directory strategy. Only affects newly spawned panes
/// (the daemon reads `config.json` fresh per spawn), like the shell setting.
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;
cfg.save();
cx.notify();
}
/// Persist the custom working-directory path from the settings input. Only
/// used when the strategy is `Custom`, but stored regardless so switching back
/// restores it.
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;
};
let cfg = cx.global_mut::<Config>();
if cfg.working_directory.path == path {
return;
}
cfg.working_directory.path = path;
cfg.save();
cx.notify();
}
/// The active tab's settings state, if it is the settings tab.
/// The open settings page's state, if the overlay is showing. The single
/// accessor every settings widget/handler reads, so the rest of the settings
/// code is agnostic to where the state lives.
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()
}
/// The status-dot colour for a tab whose representative pane is an SSH
/// session (PRD FR-E2), as an RGB value from the same hardcoded semantic
/// palette as [`AgentStatus::dot_rgb`] — not the theme's UI tokens, which
/// in this app are soft neutral fills (accent is the list-selection grey)
/// and read as no state at all. Native panes are phase-coloured
/// (connecting = amber, connected = green, failed/disconnected = red); a
/// foreground `ssh` typed into a shell gets a plain neutral dot. `None`
/// for non-SSH tabs (no dot).
///
/// [`AgentStatus::dot_rgb`]: crate::core::cli_agent::AgentStatus::dot_rgb
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.read(cx);
if let Some(phase) = v.ssh_phase() {
// Native pane.
let rgb = if v.ssh_disconnected() {
0xEF4444 // red: link lost
} else {
match phase {
SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B, // amber: in flight
SshPhase::Connected => 0x22C55E, // green: link up
SshPhase::Failed { .. } => 0xEF4444, // red: never made it
}
};
Some(rgb)
} else if v
.remote_context()
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
{
// A foreground `ssh` typed into a shell: a plain neutral dot. The
// kind check matters: a WSL pane also carries a `RemoteContext` (so
// its cwd is treated as foreign — see `local_cwd`), but it is not an
// SSH session and this dot means "SSH".
Some(0x9CA3AF)
} else {
None
}
}
/// Whether `leaf` is a live, connected native-SSH pane whose effective
/// warn-on-close is on (per-profile override, else the global toggle).
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)
}
/// Whether the tab at `index` holds any live warn-on-close SSH pane (FR-E3).
pub(crate) fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool {
self.tabs
.get(index)
.map(|t| t.pane.leaves().iter().any(|l| self.leaf_is_warn_ssh(l, cx)))
.unwrap_or(false)
}
/// Whether the focused pane is a live warn-on-close SSH pane (FR-E3).
pub(crate) fn focused_pane_is_warn_ssh(&self, window: &Window, cx: &App) -> bool {
self.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.map(|l| self.leaf_is_warn_ssh(&l, cx))
.unwrap_or(false)
}
/// Proceed with a pending SSH-close after confirmation (FR-E3).
pub(crate) fn confirm_ssh_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match self.ssh_close_confirm {
Some(SshCloseKind::Tab(i)) => self.close_tab(i, window, cx),
Some(SshCloseKind::Pane) => self.close_pane(window, cx),
None => {}
}
}
/// Dismiss the SSH-close confirmation, leaving the session open (FR-E3).
pub(crate) fn cancel_ssh_close(&mut self, cx: &mut Context<Self>) {
self.ssh_close_confirm = None;
cx.notify();
}
/// The focused pane when it is an SSH session of either kind.
///
/// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too,
/// so that its cwd is treated as foreign (see `TerminalView::local_cwd`),
/// and it must not reach anything SSH-shaped from here.
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))
}
/// The focused pane when it is a *connected native* SSH session — the gate for
/// the pane's tunnel / SFTP action buttons (top-right of the terminal body).
/// `None` for a foreground `ssh`, a still-connecting native pane, or a non-SSH
/// pane, so those never grow the action buttons.
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))
}
/// Select a sidebar section in the settings page (no-op when it's closed).
pub(crate) fn select_settings_section(
&mut self,
target: SettingsSection,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
s.section = target;
// Leaving the Keybindings page abandons any in-progress capture, so
// the interceptor doesn't keep swallowing keys off-screen.
s.recording = None;
// Entering Agents re-reads the hook install states, so edits made
// behind the panel's back (another tty7, a hand edit) show up.
if target == SettingsSection::Agents {
s.agent_hooks_states = Self::agent_hooks_snapshot();
}
}
cx.notify();
}
/// Every hook-capable agent paired with its current on-disk install state,
/// in the order the Agents section lists them.
fn agent_hooks_snapshot() -> Vec<(
crate::core::agent_hooks::HookAgent,
crate::core::agent_hooks::HooksState,
)> {
crate::core::agent_hooks::HookAgent::ALL
.into_iter()
.map(|agent| (agent, crate::core::agent_hooks::hooks_state(agent)))
.collect()
}
/// Settings → Agents: install (or rewrite in place) one agent's hooks,
/// then fold the outcome back into the panel — status row + note line.
pub(crate) fn settings_install_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
let result = crate::core::agent_hooks::install_hooks(agent);
self.finish_agent_hooks_action(agent, result, cx);
}
/// Settings → Agents: remove one agent's tty7 hooks (user hooks survive).
pub(crate) fn settings_uninstall_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
let result = crate::core::agent_hooks::uninstall_hooks(agent);
self.finish_agent_hooks_action(agent, result, cx);
}
/// Shared tail of the Agents-section hook actions: re-read the on-disk
/// states (the ground truth, whatever the action just did) and surface the
/// action's own summary or error as the note under that agent's row.
fn finish_agent_hooks_action(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
result: anyhow::Result<String>,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_states = Self::agent_hooks_snapshot();
s.agent_hooks_note = Some((
agent,
match result {
Ok(summary) => summary,
Err(e) => format!("Failed: {e}"),
},
));
}
cx.notify();
}
/// Keep the settings selection on a section that has search hits: if the
/// query changed and the current section no longer matches, jump to the
/// best-matching one so the shown page always reflects the search. A section
/// that still has matches is left alone, so the user's own click isn't yanked
/// away as they keep typing.
pub(crate) fn autoselect_settings_search(&mut self, cx: &mut Context<Self>) {
let Some(settings) = self.settings.as_ref() else {
return;
};
let query = settings.search.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return;
}
if crate::ui::settings::section_match_count(settings.section, &query) > 0 {
return;
}
if let Some(best) = crate::ui::settings::best_matching_section(&query) {
self.select_settings_section(best, cx);
}
}
// ----- Keybindings editing (Settings → Keybindings) --------------------
/// Begin capturing a new shortcut for `action`: install a keystroke
/// interceptor that swallows the next keypress and records it, and stash it
/// on the settings state so it stays active only while recording. Any prior
/// capture is replaced.
pub(crate) fn start_recording_key(
&mut self,
action: String,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// The interceptor fires app-wide *before* keymap dispatch, so a chord
// like ⌘T is captured here instead of opening a new tab. It runs until
// the returned `Subscription` is dropped (capture done / Esc / cancel).
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));
// Keep the key from also triggering an action / reaching a surface.
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();
}
/// Handle a keystroke captured during recording. Esc cancels. Backspace
/// removes the last captured chord, or — with nothing captured yet — resets
/// the action to its default. Any other key appends a chord and (re)starts
/// the pause-to-commit timer, so single chords and sequences (e.g. the tmux
/// preset's `ctrl-b x`) are recorded the same way.
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 {
// Edit the sequence: drop the last chord and keep capturing.
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 {
// Nothing left to commit; wait for a fresh keypress.
self.record_gen = self.record_gen.wrapping_add(1);
}
cx.notify();
} else {
self.stop_recording(cx);
self.reset_keybinding(action, cx);
}
return;
}
_ => {}
}
// A lone modifier press (⌘ held, no key yet) has nothing to bind — keep
// waiting for a real key.
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();
}
/// (Re)arm the pause-to-commit timer: after a short quiet window with no new
/// chord, the captured sequence is committed. Bumping `record_gen` first
/// invalidates any earlier timer, so only the latest keypress's timer fires.
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();
}
/// Commit the captured chords (joined into a sequence spec) as the action's
/// override. A no-op if capture ended or nothing was captured.
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);
}
/// Drop the active capture (interceptor released, any pending commit timer
/// invalidated) without changing anything.
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();
}
/// Assign `spec` to `action`. If another action already owns that keystroke,
/// unbind it (last-writer-wins would otherwise be order-dependent) and note
/// the takeover so the user can undo it with a reset.
fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) {
// Find the current owner of this exact keystroke, if it isn't `action`.
let displaced = crate::ui::keymap::effective_bindings(cx)
.into_iter()
.find(|(a, k)| *k == spec && *a != action)
.map(|(a, _)| a);
let note = displaced.as_ref().map(|other| {
format!(
"{} took the shortcut from {}, which is now unset.",
humanize_action(&action),
humanize_action(other)
)
});
self.update_config(cx, |cfg| {
if let Some(other) = &displaced {
// Explicit empty override = "unbound" (distinct from a reset,
// which would restore that action's default and re-conflict).
cfg.keybindings.insert(other.clone(), String::new());
}
cfg.keybindings.insert(action, spec);
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.rebinding_note = note;
}
cx.notify();
}
/// Reset one action to its built-in default (drop its override) and
/// re-install the keymap.
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();
}
/// Clear every keybinding override, restoring the full default table.
pub(crate) fn restore_default_keybindings(&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();
}
/// Switch the keybinding preset ("default" / "tmux") and re-install the
/// keymap so the change is live immediately.
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();
}
/// Set the tmux preset's prefix chord (e.g. `ctrl-b` / `ctrl-a`) and
/// re-install the keymap.
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();
}
/// Open `config.json` with the OS default handler (Settings → Keybindings).
/// A fresh install may never have saved yet, so write the current config
/// first — the button must not point at a missing file.
// The "Open config file" button was temporarily pulled from the UI; keep the
// handler around so re-enabling it is a one-line change in `settings.rs`.
#[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());
}
}
/// Open the GitHub Releases page in the browser — the "Download" action of
/// the Settings → About update prompt. Deliberately hand-off, not
/// self-update: the newest build is one click away on the web. Delegates to
/// `core::update` so the settings button and the update modal share it.
pub(crate) fn open_releases_page(&self) {
crate::core::update::open_releases_page();
}
}
impl Render for Tty7App {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// A live drag-reorder commits when the drag *ends*, which in gpui means
// the mouse was released (nothing else clears an active drag): the first
// frame without one retires the preview and applies the order it was
// last showing. Deliberately not an `on_drop` handler — those only fire
// when the pointer is over that particular element at release, so a
// release a hair outside the rail or the strip would silently lose the
// move. What you were looking at is what you get, wherever you let go.
// One place at the root covers every surface that can start a drag.
if cx.has_active_drag() {
// Still dragging: forget last frame's answer so only what this
// frame actually draws can be committed (see `clear_pending`).
crate::ui::reorder::clear_pending(&self.reorder);
} else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
self.apply_tab_order(&order, cx);
}
// While a tab or group is in hand, the cursor is a closed hand for the
// whole window. It has to be set on the *drag* rather than styled on
// the element: gpui overrides every hovered element's cursor with the
// active drag's for the duration, and that override is `None` — a plain
// arrow — unless something fills it in. Set once per drag (it forces a
// refresh, so re-setting it every frame would spin).
if self.reorder.borrow().is_some()
&& cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand)
{
cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window);
}
// Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip
// drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the
// zero-tab home page keeps the full-width horizontal layout, so an empty
// rail never appears.
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
// The rail can be collapsed away without leaving `Left` mode. When it is,
// the layout below has no left column, so the title strip takes over the
// rail's jobs: it reserves the traffic lights and carries the sidebar's
// own controls (new tab + expand) at its left edge.
let rail = vertical && !self.sidebar_collapsed;
let strip = self.tab_strip(!vertical, window, cx);
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
// Native-SSH status strip / reconnect notice for the focused pane (E1/E4).
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));
// Render the active tab's pane tree; show focus rings only when split.
let body = match self.tabs.get(self.active) {
// Zero tabs: the window's own face — the home page (see `ui::home`).
None => self.render_home(cx).into_any_element(),
Some(active_tab) => {
// If a pane is maximized and it belongs to the active tab, render
// just that leaf full-window; otherwise the normal split layout.
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 show_focus = active_tab.pane.leaves().len() > 1;
active_tab.pane.render(show_focus, window, cx)
}
}
}
};
// The title strip (a transparent unified title bar carrying `strip`) and
// the terminal body area — shared by both layouts.
let title_bar = TitleBar::new()
// Taller than the stock 34px bar so the tabs read substantial and
// roomy instead of cramped. `.h(..)` lands in the component's
// `refine_style`, applied after its own `.h(TITLE_BAR_HEIGHT)`, so
// this override wins.
.h(px(TITLE_BAR_HEIGHT))
.bg(cx.theme().transparent)
.border_color(cx.theme().transparent)
.child(strip);
let body_area = div()
.flex_1()
.relative()
.overflow_hidden()
.child(body)
// Nothing of the SSH tooling floats over the terminal any more: port
// forwarding is a band on the detail panel's Info tab, the remote file
// browser is its Files tab, and transfers are the panel's footer. That
// also gives the ⌘F find bar the top-right slot back — it used to have
// to fight the tunnel/SFTP icons for it.
//
// In-pane native-SSH auth / host-key sheet (WS3), shown over the pane
// that raised the prompt.
.when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| {
this.child(el)
})
// Native-SSH status strip / reconnect notice (E1/E4).
.when_some(ssh_status, |this, el| this.child(el))
// Live-SSH close-confirmation sheet (E3).
.when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| {
this.child(el)
})
// "New Worktree Tab" confirmation sheet (from the tab context menu).
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
this.child(el)
});
// Working-tree diff overlay — mounted on the *column*, not on
// `body_area`, so it covers the title strip too and reads as one
// surface the way the code overlay does. Like that overlay it stops at
// the rail and the right panel (both are siblings), which is the point:
// the sidebar's git lines stay clickable to switch repo, and the
// Changes list stays put so you can walk down it file by file.
let diff_overlay = self.render_diff_overlay(cx);
// Code panel: an immersive overlay ([file tree | editor], IDE-style)
// covering the title strip *and* the terminal — the whole column right
// of the tab sidebar — so nothing of the terminal chrome distracts.
// The terminal underneath keeps its size (no PTY resize/reflow), and
// the sidebar stays visible: switching tabs re-roots the tree.
let code_overlay = self.render_code_overlay(window, cx);
// The two column overlays, ordered so the one the user last acted on is
// the later child and therefore paints on top. Neither outranks the
// other by construction.
let overlays: Vec<gpui::AnyElement> = {
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()
};
// The layout. The rail (vertical mode) is a full-height *left column* that
// reaches the very top of the window — the traffic lights sit on its
// surface — with the title strip and terminal stacked in the right column.
// That way the rail surface has no seam with the title bar and reads as one
// continuous panel.
//
// The right detail panel does the same on macOS: a full-height column
// carrying its own title-bar-height top zone (tab row + the window's corner
// chrome), so its surface runs unbroken from the very top of the window.
//
// Off macOS it can't. The window controls (─ ▢ ✕) are laid out by the title
// bar itself, at *its* right end, so a full-height panel beside the bar
// strands them mid-window with the panel's grey to their right. There the
// bar spans the panel too — reaching the real top-right corner, where
// Windows and Linux users expect the controls — and the panel hangs below
// it, VS Code style. The panel's tab row then sits on the panel (no longer
// in the caption row), and the corner chrome stays in the strip.
let right_panel = self.render_right_panel(window, cx);
let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos");
// Which host the bar goes to: the terminal column's first child, or the
// spanning row above [terminal | panel].
let (column_title_bar, spanning_title_bar) = if panel_below_title_bar {
(None, Some(title_bar))
} else {
(Some(title_bar), None)
};
// And where the overlays hang. Normally on the terminal column, which they
// fill: the bar is that column's first child, so an `inset_0` overlay
// covers it and the overlay's own header row lands *on* the caption line —
// which is what both headers are drawn for (title-bar height, the bar's
// insets, a full-size chrome tile for their one control).
//
// With the bar hoisted, a column-anchored overlay starts 40px down and its
// header sits one row too low: level with the panel's tab row instead of
// with the caption. So it hangs on the row that owns the bar instead,
// inset from the right by the panel's width — covering the bar's band over
// the terminal column (which carries nothing there but the drag region, or
// the rail's controls while it's collapsed: exactly what an overlay covers
// with the panel closed) and stopping short of the panel, so the ─ ▢ ✕
// group and the corner chrome keep their own surface and their clicks.
let (column_overlays, hoisted_overlays) = if panel_below_title_bar {
(Vec::new(), overlays)
} else {
(overlays, Vec::new())
};
let panel_px = self.right_panel_px(window, cx);
// The terminal column, and the anchor for both overlays: they fill it —
// and, since the panel is a sibling rather than a child, stop short of the
// panel for free. With the bar spanning above, they stop short of it too,
// which keeps the native controls clickable while an overlay is open and
// lines the overlay's own header row up with the panel's tab row.
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(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()
// The containing block for the hoisted overlays below.
.relative()
.child(
// The bar's own band over the panel, painted in the panel's
// surface so the column still reads as one continuous
// sidebar from the very top of the window — the rail's
// trick, kept now that the tab row moved off the caption
// line. Without it the panel's grey started 40px down and
// the corner tore into two colours.
//
// A sibling *under* the transparent bar rather than padding
// inside it: the ─ ▢ ✕ group is the bar's own last child, so
// nothing laid out in the bar can get behind the controls,
// and only a layer below can carry a surface under them.
// Same width and left border as the panel, both read from
// `right_panel_px`, so the edge stays in register through a
// resize drag.
div()
.relative()
.flex_none()
.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_0()
.w(px(self.right_panel_px(window, cx)))
.bg(cx.theme().sidebar)
.border_l_1()
.border_color(cx.theme().sidebar_border),
)
.child(bar),
)
.child(panel_row)
// Last child, so they paint over both the bar and the column.
// Each overlay is `absolute().inset_0()` against this wrapper,
// which is the only thing that has to know where the panel
// starts.
.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();
// The real window background paint: gradient-aware and opacity-carrying
// (see `theme::window_background`), plus the theme's optional background
// image. Falls back to the component theme's solid before the first
// `apply_theme` has published the global.
let (window_bg, bg_image) = match cx.try_global::<crate::ui::presets::ActiveBackground>() {
Some(bg) => (window_background(bg), bg.image.clone()),
None => (cx.theme().background.into(), None),
};
// Settings is a full-window overlay (not a tab): it covers the tab rail,
// title strip, and terminal so it never crowds the tab list. `occlude`
// blocks input to the elements behind it. It fills the window edge to
// edge — its own nav sidebar reserves the title-bar zone internally (so
// that rail reaches the top like the tab rail), rather than insetting the
// whole page here.
let settings_overlay = self.settings.is_some().then(|| {
div()
.absolute()
.inset_0()
.occlude()
// Same gradient-aware paint as the root, so a gradient theme's
// settings page doesn't snap to a flat color. A translucent
// theme's alpha rides along, letting the background image show
// through here too. This second layer compounds the alpha over
// the root's paint — deliberate: the overlay must occlude the
// terminal behind it to stay readable.
.bg(window_bg)
.child(self.render_settings(cx))
});
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, _: &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| {
// A fresh workspace, not a copy of this one: the daemon gives
// each pane a single subscriber, so a second window onto the
// same panes would steal this window's output.
crate::ui::windows::open(cx, None);
}))
.on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| {
// With focus in the editor panel, ⌘W closes the active file
// tab instead of the terminal pane/tab.
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.cycle_tab(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &PrevTab, 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(|_, _: &ToggleFullscreen, window, _cx| window.toggle_fullscreen()),
)
.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, _: &ShowRightPanelOutline, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Outline, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Changes, 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, _: &OpenSettings, window, cx| this.toggle_settings(window, cx)),
)
.on_action(
cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_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| this.editor_save_active(window, cx)),
)
// Quit lives on the same element-tree action path as every other Cmd
// shortcut above, so a focused terminal routes `cmd-q` here rather
// than relying solely on the global handler (which the keystroke
// doesn't reach while focus is deep in the terminal view).
.on_action(cx.listener(|_, _: &Quit, _, cx| cx.quit()))
.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)
}))
// Tab operations that used to be reachable only by right-clicking a
// chip. Each targets the active tab, so the menu bar / palette /
// keyboard all mean "this tab" without a click to say which.
.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)
}))
// Settings destinations that deserve their own way in: Help →
// Keyboard Shortcuts and the App menu's About both used to require
// opening Settings and then hunting for the section.
.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)
}))
// Standard macOS App / Window menu items. gpui exposes the platform
// calls but ships no actions for them.
.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()))
// Help destinations. Opened in the default browser; a failure here is
// not worth interrupting the user over, so it is logged, not toasted.
.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)))
// The theme's background image, composited over the background fill
// at its own opacity and under all content. Absolute, so it doesn't
// participate in the flex column; the wrapper clips the Cover
// overflow (gpui's `img` paints the fitted bounds unclipped).
.when_some(bg_image, |this, image| {
this.child(
div()
.absolute()
.inset_0()
.overflow_hidden()
.opacity(image.opacity)
.child(
img(image.path)
.size_full()
.object_fit(gpui::ObjectFit::Cover),
),
)
})
.child(main_layout)
// Settings overlay, above the tabs/terminal when open.
.when_some(settings_overlay, |this, overlay| this.child(overlay))
// Command palette overlay, layered above everything when open.
.when_some(self.palette.clone(), |this, palette| this.child(palette))
// Toast layer for `window.push_notification` (worktree/SSH errors).
// gpui-component's Root only *stores* the list; the root view must
// render the layer — without this child every toast was invisible.
.children(gpui_component::Root::render_notification_layer(window, cx))
}
}
/// Convert a live `Tab` (pane tree + name) into its serializable mirror.
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(),
}
}
/// Convert a live `Pane` tree into its serializable mirror, reading each
/// leaf's current cwd and each split's axis + ratio. Used when saving.
fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
match pane {
Pane::Leaf(view) => {
let view = view.read(cx);
SessionPane::Leaf {
// Local cwd only. A restored pane whose daemon pane is gone
// respawns on the *default local shell* (a shell pick isn't
// persisted), so a remote cwd would come back paired with a
// local shell that cannot chdir into it. Native-SSH panes
// reconnect from `ssh_spec` and the daemon discards the cwd
// for them anyway (`server::SpawnNativeSsh`).
cwd: view.local_cwd(),
pane_id: Some(view.pane_id),
// Persist the secret-free native-SSH spec so a *dead* pane can be
// reconnected on restore (FR-E4/C2); `None` for local panes. A
// live pane reattaches by `pane_id` and never needs this.
ssh_spec: view.ssh_spec(),
// The running agent + its native session id (when its hooks
// reported one), so a pane the daemon loses can resume the
// agent conversation instead of just reopening a shell. The
// observed launch argv rides along so the resume command keeps
// the user's flags (`--dangerously-skip-permissions`, …).
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)),
},
// A transient `Empty` should never be persisted; mirror it as a bare
// leaf so restore still yields a usable terminal.
Pane::Empty => SessionPane::Leaf {
cwd: None,
pane_id: None,
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
},
}
}
/// Set of daemon pane ids currently alive, used by `session_to_pane` to decide
/// per leaf whether to re-`attach` or `spawn`. Computed once per restore from the
/// daemon's `List`; empty (→ all-fresh) when the daemon is unreachable.
pub(crate) fn alive_panes() -> std::collections::HashSet<u64> {
crate::terminal::RemoteTerminal::list_panes()
.into_iter()
.filter(|p| p.alive)
.map(|p| p.pane_id)
.collect()
}
/// Rebuild the tab list from a persisted `Session`, re-attaching to still-live
/// daemon panes where possible and spawning fresh shells otherwise. An absent or
/// empty session yields no tabs (the home page). Shared by first-launch restore
/// (`Tty7App::for_workspace`) and the daemon-restart rebuild (`restart_daemon`), so the two
/// stay in lockstep.
fn tabs_from_session(
session: Option<Session>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> (Vec<Tab>, usize) {
let Some(session) = session.filter(|s| !s.tabs.is_empty()) else {
return (Vec::new(), 0);
};
// Ask the daemon once which panes are still alive, so leaves re-attach to
// surviving shells instead of all spawning fresh.
let alive = alive_panes();
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
for st in &session.tabs {
// A tab whose every leaf failed to come back has nothing to show; drop
// it rather than restore an empty frame (or, worse, abort the launch).
let Some(pane) = session_to_pane(&st.pane, &alive, font_size, window, cx) else {
log::error!("dropping a restored tab: no pane in it could be started");
continue;
};
tabs.push(Tab {
pane,
name: st.name.clone(),
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
// Seed the sticky group from the saved session so the sidebar
// renders grouped on the first frame; the first landed probe
// corrects it if the tab's repo changed while we were gone.
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
});
}
// Clamp the saved active index into the rebuilt range (which can be empty
// when nothing restored).
let active = session.active.min(tabs.len().saturating_sub(1));
(tabs, active)
}
/// Rebuild a live `Pane` tree from a saved `SessionPane`. A leaf whose saved
/// `pane_id` is still alive in the daemon re-`attach`es (process + scrollback
/// intact); otherwise it spawns a fresh shell in the saved cwd. `alive` is the
/// daemon's current pane set, computed once by the caller.
///
/// `None` when nothing under this node could be started (an unreachable
/// daemon): restore drops what it can't rebuild instead of leaving `Empty`
/// nodes — which every tree operation ignores — in a live tab.
fn session_to_pane(
sp: &SessionPane,
alive: &std::collections::HashSet<u64>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Option<Pane> {
match sp {
SessionPane::Leaf {
cwd,
pane_id,
ssh_spec,
agent,
agent_session_id,
agent_launch_argv,
} => {
// Only restore the pane id when the daemon confirms it's still live;
// a stale id (daemon restarted, pane killed) falls back to a spawn.
let restore = (*pane_id).filter(|id| alive.contains(id));
// A *dead* native-SSH leaf (spec persisted, pane no longer alive)
// reconnects rather than dropping back to a local shell (FR-C2/E4):
// re-resolve secrets from the profile when it names one, else reuse
// the secret-free spec and let the auth sheets prompt.
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(view)),
// Keep restore alive: fall through to a local shell in
// this slot rather than aborting startup.
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
}
}
}
// A shell pick isn't persisted in the session, so a stale pane that
// must respawn comes back on the default shell.
let view = match new_terminal(font_size, cwd.clone(), restore, None, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("restoring pane failed: {e}");
return None;
}
};
// A pane that could NOT re-attach lost its running agent with the
// daemon; when we captured that agent's native session id, hand
// the fresh shell its resume command so the conversation picks up
// where it left off (cmux's auto-resume, config-gated). The bytes
// sit in the PTY input queue until the shell reads its first
// command — same mechanism as tmux send-keys at spawn.
if restore.is_none()
&& cx.global::<Config>().restore_agent_sessions
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
&& let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref())
{
view.read(cx).run_command_line(&cmd);
}
Some(Pane::leaf(view))
}
SessionPane::Split { axis, ratio, a, b } => {
let axis = match axis {
SessionAxis::Horizontal => Axis::Horizontal,
SessionAxis::Vertical => Axis::Vertical,
};
// One side failing collapses the split onto the survivor, exactly
// as closing that pane by hand would.
match (
session_to_pane(a, alive, font_size, window, cx),
session_to_pane(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,
}
}
}
}
/// Build a shell-backed terminal view, wiring the per-pane subscriptions every
/// pane needs. Fallible: the daemon can refuse the spawn (it died, it's
/// wedged, the shell doesn't exist), and every caller here runs inside a gpui
/// input callback, where a panic can't unwind and would abort the app instead
/// of surfacing the failure. Report it, don't `expect` it.
fn new_terminal(
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<Entity<TerminalView>> {
let parts = TerminalView::spawn_shell_terminal(working_directory, restore_pane, shell)?;
let view = cx.new(|cx| {
let mut view = TerminalView::from_shell_parts(parts, window, cx);
// Inherit the current global font size so new panes match existing ones.
view.font_size = px(font_size);
view
});
// A pane whose shell exits on its own (`exit`, Ctrl-D, a crash) closes
// itself, like every other terminal. This is the single place all panes
// are built — new tab, split, session restore — so the subscription
// covers them all; restore even cleans up panes that died while no
// client was attached (the daemon replays their exit on reattach).
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
app.on_child_exited(view.clone(), window, cx);
})
.detach();
// Native-SSH auth/host-key prompts raised by this pane → in-pane sheet. Same
// single build site as ChildExited, so every pane (new tab, split, restore)
// is covered.
cx.subscribe_in(
&view,
window,
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
app.on_auth_prompt_ready(view.clone(), window, cx);
},
)
.detach();
watch_pane_focus(&view, window, cx);
Ok(view)
}
/// Re-render the app whenever `view` takes focus. Nothing else does this: a
/// pane owns its own focus handle, so clicking between splits notifies the
/// *pane*, not us, and any chrome that describes "the active pane" — the right
/// panel's Info and Changes tabs — would keep showing the pane you left until
/// some unrelated notify happened to repaint. Focus changes are user-paced, so
/// the extra frames are free.
fn watch_pane_focus(view: &Entity<TerminalView>, window: &mut Window, cx: &mut Context<Tty7App>) {
let handle = view.read(cx).focus_handle.clone();
let app = cx.weak_entity();
window
.on_focus_in(&handle, cx, move |_window, cx| {
if let Some(app) = app.upgrade() {
app.update(cx, |_, cx| cx.notify());
}
})
.detach();
}
/// Build a native (russh) SSH terminal view for `spec`, wiring the same
/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`]
/// so it participates in auto-close and the in-pane auth sheets. Mirrors
/// `new_terminal` but takes the resolved connect spec instead of a shell.
/// Errors (daemon down/stale, spawn refused) are returned, never panicked —
/// callers surface them and keep the app alive.
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_pane_focus(&view, window, cx);
Ok(view)
}
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)
}
/// The data a typed "SSH: Add Connection…" line resolves to: a transient profile
/// plus the raw `ProxyJump` target (from `-J`), ready for
/// [`crate::ui::ssh_connect::native_spec_from_transient_profile`].
pub(crate) struct ParsedSshConnect {
pub profile: crate::core::ssh_profile::SshProfile,
pub proxy_jump: Option<String>,
}
/// Parse a typed connect line (`[ssh] [flags] user@host[:port]`) into native
/// connect data (PRD §3.1). Only the trivially-mappable flags are honored — `-p`,
/// `-l`, `-i` (repeatable), `-J`, and `-o User=`/`-o Port=`/`-o ProxyJump=`; other
/// options are ignored (best-effort). A remote command, a `--` separator, an
/// unbalanced quote, or a missing/invalid host is an `Err(reason)` surfaced as an
/// inline notice — never a silent shell-out. Returns the user-facing reason 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(|_| "Unbalanced quotes in the SSH command".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("Remote commands aren't supported here".to_string());
}
if let Some((flag, attached)) = ssh_short_flag(&word) {
// Consume the value (attached `-p2222` form or the next word) when the
// flag takes one.
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(format!("-{flag} needs a value")),
}
}
} else {
String::new()
};
match flag {
'p' => {
port = Some(
value
.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("Invalid port \u{201c}{value}\u{201d}"))?,
)
}
'l' => user = Some(value),
'i' => identities.push(value),
'J' => jump = Some(value),
'o' => apply_ssh_o_option(&value, &mut user, &mut port, &mut jump)?,
// Any other flag (value already consumed if it took one) is ignored.
_ => {}
}
} else if word.starts_with('-') {
// A long option (`--foo`) or bare `-`: not something we map.
return Err(format!("Unsupported option \u{201c}{word}\u{201d}"));
} 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(|| "Enter a host to connect to".to_string())?;
let qc = parse_quick_connect(&target)
.ok_or_else(|| format!("Can't parse host \u{201c}{target}\u{201d}"))?;
let mut profile = SshProfile::new(qc.host.clone());
profile.host = qc.host;
// Explicit `-p` / `-o Port=` wins over a `:port` on the target, else default 22.
profile.port = port.or(qc.port).unwrap_or(22);
// Explicit `-l` / `-o User=` wins over `user@` on the target.
if let Some(user) = user.or(qc.user) {
profile.user = user;
}
profile.identity_files = identities;
Ok(ParsedSshConnect {
profile,
proxy_jump: jump,
})
}
/// Split a short-option word into `(flag, attached_value)` — `-p2222` → `('p',
/// "2222")`, `-J` → `('J', "")`. `None` for a non-option, `--`/long option, or a
/// bare `-`.
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()))
}
/// Apply the trivially-mappable `-o Name=Value` options (`User`/`Port`/
/// `ProxyJump`); anything else is ignored (best-effort).
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(|| format!("Invalid port \u{201c}{val}\u{201d}"))?,
)
}
"proxyjump" => *jump = Some(val.to_string()),
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{parse_ssh_connect_input, parse_ssh_option_words};
#[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() {
// Bare `user@host:port` (optional `ssh` prefix) → transient 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() {
// Options before and after the target; `-p`/`-l`/`-i`/`-J` all map.
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"));
// Attached short-flag form (`-p2222`) and `-o User=`/`-o Port=`.
let p = parse_ssh_connect_input("host -p2222 -o User=deploy -o Port=2200").unwrap();
assert_eq!(p.profile.user, "deploy");
// `-o Port=` wins over an earlier `-p` (last write wins in the -o pass).
assert_eq!(p.profile.port, 2200);
}
#[test]
fn explicit_flags_override_target_userhost() {
// `-l` / `-p` override the `user@host:port` on the target.
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() {
// No host at all.
assert!(parse_ssh_connect_input("ssh -p 2222").is_err());
// A remote command or `--` separator is not a connect line.
assert!(parse_ssh_connect_input("ssh dev uptime").is_err());
assert!(parse_ssh_connect_input("ssh -- dev").is_err());
// Unbalanced quote.
assert!(parse_ssh_connect_input("ssh 'host").is_err());
// Invalid port.
assert!(parse_ssh_connect_input("ssh host -p 0").is_err());
}
}
#[cfg(test)]
mod keybinding_gpui_tests {
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::settings::SettingsSection;
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
// The pause-to-commit is a real `smol::Timer` (off the deterministic
// executor), so waiting on it parks the test thread.
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
});
// Wrap the app in a `gpui_component::Root` exactly like `main.rs` does:
// the settings overlay's search box (and other gpui-component widgets)
// reach for `Root` on the window, which panics if the window's first
// layer isn't one. `Root::view()` hands the typed app entity back so the
// tests still drive `Tty7App` directly.
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)
}
/// Open Settings → Keybindings and begin capturing `action`.
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);
});
}
/// Poll (bounded) until `action` has the expected override in config — the
/// commit fires on a real ~650ms timer.
fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
vcx.background_executor.run_until_parked();
let got = vcx.update(|_, cx| cx.global::<Config>().keybindings.get(action).cloned());
if got.as_deref() == Some(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));
}
}
// End-to-end: open Settings → Keybindings, capture a shortcut for New Tab,
// and confirm the recorded keystroke is normalized, persisted to config, and
// the capture ends. This drives the real interceptor path installed by
// `start_recording_key`, not just the pure helpers.
#[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");
// The platform-primary modifier normalizes to `secondary` on write.
vcx.simulate_keystrokes("secondary-shift-n");
wait_for_binding(&mut vcx, "NewTab", "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"
);
}
// A two-chord sequence (the tmux-style `ctrl-b x`) records as one binding.
#[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");
// Two chords in quick succession, then the pause commits the sequence.
// `secondary-b` is used (not a bare `ctrl-b`) so the recorded spec is
// identical on macOS and elsewhere — the primary modifier normalizes to
// `secondary` either way.
vcx.simulate_keystrokes("secondary-b");
vcx.simulate_keystrokes("x");
wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x");
}
// Esc during capture cancels without touching config.
#[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));
}
}