Files
tty7/src/ui/app.rs
T
l0ng-ai cdb383d6ce feat(shell): detect installed shells, pick one from the "+" dropdown
The "+" button now opens a shell picker listing every shell found on
this machine — the login shell plus /etc/shells on Unix; PowerShell 7,
Windows PowerShell, cmd, Git Bash and WSL distributions on Windows — so
opening a tab in a different shell no longer requires hand-editing
config.json. The default entry leads the menu (Cmd+T still opens a
default tab in one keystroke), and splits inherit the pane's shell.

The Windows default shell now prefers PowerShell 7 when installed,
probed the way Warp does it (Program Files x64/x86/ARM, the Microsoft
Store shim, scoop, dotnet tools, then PATH), falling back to the
Windows PowerShell that ships with the OS.

Spawn precedence in the daemon is now: explicit per-spawn pick >
config.json `shell` > platform default. Wire compat is preserved across
GUI/daemon version skew: a default spawn keeps the legacy SPAWN frame
byte-for-byte, so an old daemon still serves a new GUI; only an
explicit pick uses the new SPAWN_SHELL kind, locked by a round-trip +
legacy-frame test.

Claude-Session: https://claude.ai/code/session_01ABey161AUxhgmJC3PRoYtF
2026-07-07 23:31:54 +08:00

1938 lines
80 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, Context, Entity, KeyDownEvent, PromptLevel, Subscription, Window, div, 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, TitleBar};
use crate::core::actions::*;
use crate::core::config::{Config, NewTabPosition, ShellConfig, color_or, hsla_to_hex6};
use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab};
use crate::core::shells::DetectedShell;
use crate::daemon::protocol::ShellSpec;
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
use crate::ui::pane::{CloseOutcome, Pane};
use crate::ui::settings::{ColorKey, SettingsSection, SettingsState};
use crate::ui::theme::{apply_theme, set_menus};
/// 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;
/// One tab: a split-pane tree plus an optional user-assigned name.
pub struct Tab {
/// The tab's split-pane tree (one or more terminals). For a settings tab
/// this is `Pane::Empty` — the body renders the settings panel instead.
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>,
/// `Some` for the dedicated settings tab; `None` for a normal terminal tab.
/// A settings tab carries its own panel state and is never persisted.
settings: Option<SettingsState>,
}
impl Tab {
fn new(pane: Pane) -> Self {
Self {
pane,
name: None,
settings: None,
}
}
/// True for the dedicated settings tab.
pub(crate) fn is_settings(&self) -> bool {
self.settings.is_some()
}
/// The title of the tab's representative terminal (its first leaf), used to
/// derive both the tab label and its context icon. Empty when there's no
/// terminal or no title yet.
pub(crate) fn leaf_title(&self, cx: &App) -> String {
self.pane
.first_leaf()
.map(|l| l.read(cx).title.clone())
.unwrap_or_default()
}
}
/// 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>,
}
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>,
/// 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,
/// `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>,
/// 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,
/// 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>,
}
impl Tty7App {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Font size from config (borrow ends before the mutable theme apply).
let font_size = cx.global::<Config>().font_size;
let line_height = cx.global::<Config>().line_height;
let font_family = cx.global::<Config>().font_family.clone();
let font_family_bold = cx.global::<Config>().font_family_bold.clone();
let font_family_italic = cx.global::<Config>().font_family_italic.clone();
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
// `Config` global on every `config.json` change, which fires this. Theme
// and colors are handled separately by `apply_theme`; here we cover the
// font knobs that live on `Tty7App`/the panes.
let config_watch = cx.observe_global::<Config>(|this, cx| this.reload_from_config(cx));
// 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));
});
// Paint the configured color theme (defaults to a light one) and build
// the menu bar.
apply_theme(Some(window), cx);
set_menus(cx);
// Try to restore the previous session (tab/split layout + each pane's
// cwd). 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::load() {
// 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 => {
let first = new_terminal(font_size, None, None, None, window, cx);
(vec![Tab::new(Pane::leaf(first))], 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),
};
let app = Self {
tabs,
active,
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
_config_watch: config_watch,
_keystroke_watch: keystroke_watch,
palette: None,
palette_sub: None,
closed: Vec::new(),
renaming: None,
maximized: None,
mod_hint_badges: false,
mod_hint_gen: 0,
home_focus: cx.focus_handle(),
detected_shells: Vec::new(),
};
// 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;
let _ = this.update(cx, |app, _| app.detected_shells = shells);
})
.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);
async move {}
})
.detach();
// Confirm before the red traffic light closes the window. Closing quits
// the app, but the panes are *detached, not killed* — they keep running in
// the daemon and re-attach on the next launch — so the prompt reassures
// rather than warns. We veto the immediate close (return `false`), show the
// prompt, and quit only if the user picks "Close". A one-shot flag lets
// that post-confirm quit through should we be asked again, instead of
// looping the prompt.
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;
}
// From the home page (zero tabs) there are no running sessions to
// reassure about — prompting would be pure friction. Close directly.
if weak_app
.upgrade()
.is_some_and(|app| app.read(cx).tabs.is_empty())
{
return true;
}
let answer = window.prompt(
PromptLevel::Info,
"Close Window?",
Some(
"Your sessions keep running in the background and will be \
restored the next time you open tty7.",
),
&["Cancel", "Close"],
cx,
);
let close_confirmed = close_confirmed.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);
cx.update(|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`.
fn save_session(&self, cx: &App) {
// The settings tab is ephemeral — exclude it, and clamp `active` into the
// remaining terminal tabs so the next launch restores a real tab.
let tabs: Vec<SessionTab> = self
.tabs
.iter()
.filter(|tab| !tab.is_settings())
.map(|tab| tab_to_session(tab, cx))
.collect();
// Zero terminal 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.
if tabs.is_empty() {
Session::default().save();
return;
}
// Remap `self.active` (an index into the *unfiltered* tabs) into the
// filtered list: it's the number of non-settings tabs before it. A plain
// `min` clamp is wrong when the settings tab sits *before* the active one
// — it would shift the restored selection onto the wrong tab.
let active = self.tabs[..self.active.min(self.tabs.len())]
.iter()
.filter(|tab| !tab.is_settings())
.count()
.min(tabs.len() - 1);
let session = Session { active, tabs };
session.save();
}
/// 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 pane = session_to_pane(&st.pane, &alive, self.font_size, window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(
insert_at,
Tab {
pane,
name: st.name,
settings: None,
},
);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// 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 Background Service?",
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;
}
// 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;
let (tabs, active) =
tabs_from_session(Session::load(), 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.
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
cx.global_mut::<Config>().theme_preset = id.to_string();
apply_theme(Some(window), cx);
set_menus(cx);
cx.global::<Config>().save();
cx.notify();
}
/// Apply a picked color to one `colors.*` override and re-paint the theme
/// live. `None` clears the override, falling the slot back to the active
/// preset's default. Persisted so it survives a restart. `apply_theme` already
/// reads `cfg.colors.*`, so writing the field + re-applying is all it takes.
pub(crate) fn set_color_override(
&mut self,
key: ColorKey,
value: Option<gpui::Hsla>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let new = value.map(hsla_to_hex6);
let cfg = cx.global_mut::<Config>();
if key.get(&cfg.colors) == &new {
return; // no change — skip the theme re-apply + disk write
}
key.set(&mut cfg.colors, new);
cfg.save();
apply_theme(Some(window), cx);
cx.notify();
}
/// Clear one `colors.*` override back to the theme default, and sync the row's
/// picker swatch to the now-effective (preset) color.
pub(crate) fn reset_color_override(
&mut self,
key: ColorKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
{
let cfg = cx.global_mut::<Config>();
if key.get(&cfg.colors).is_none() {
return;
}
key.set(&mut cfg.colors, None);
cfg.save();
}
apply_theme(Some(window), cx);
// Reflect the default in the picker swatch so it doesn't keep showing the
// cleared override color.
let neutrals = {
let cfg = cx.global::<Config>();
crate::ui::presets::by_id(&cfg.theme_preset).neutrals()
};
let picker = self.active_settings().and_then(|s| {
s.color_pickers
.iter()
.find(|(k, _)| *k == key)
.map(|(_, state)| state.clone())
});
if let Some(state) = picker {
let default = color_or(&None, key.default_u32(&neutrals));
state.update(cx, |s, cx| s.set_value(default, window, cx));
}
cx.notify();
}
/// Switch the cursor shape and repaint. The element reads `cursor_style` from
/// the global each frame, so we just persist and nudge every pane to redraw.
pub(crate) fn set_cursor_style(
&mut self,
style: crate::core::config::CursorStyle,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.cursor_style = style);
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |_v, cx| cx.notify());
}
}
}
// ── 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.
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);
}
/// 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);
}
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)
});
}
pub(crate) fn set_new_tab_position(&mut self, pos: NewTabPosition, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.new_tab_position = pos);
}
pub(crate) fn set_notify_mode(
&mut self,
mode: crate::core::config::NotifyMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.notify_on_command_finish = mode);
}
// ── Input / Mouse setters ───────────────────────────────────────────────
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);
}
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_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.startup_mode = mode);
}
fn focus_active(&self, window: &mut Window, cx: &mut App) {
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;
};
// Settings tab: route focus to its panel so Esc-to-close works.
if let Some(settings) = tab.settings.as_ref() {
window.focus(&settings.focus_handle, cx);
} else if let Some(leaf) = tab.pane.first_leaf() {
let handle = leaf.read(cx).focus_handle.clone();
window.focus(&handle, cx);
}
}
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);
}
/// 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.
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 tab = new_terminal(self.font_size, cwd, None, shell, 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();
}
/// 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).
let cwd = target.read(cx).cwd();
let shell = target.read(cx).shell_spec();
let new = new_terminal(self.font_size, cwd, None, shell, window, cx);
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>) {
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)
};
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();
}
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if index < self.tabs.len() && index != self.active {
self.maximized = None;
self.active = index;
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;
}
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;
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back. The settings
// tab is ephemeral, so it is never snapshotted or reopened this way.
if !self.tabs[index].is_settings() {
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();
}
/// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop).
/// Keeps the same tab active across the move and re-persists the session.
pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
return;
}
// Reordering shifts indices; a pending rename keyed on a fixed index would
// commit onto the wrong tab. Drop it.
self.renaming = None;
let was_active = self.active;
let tab = self.tabs.remove(from);
self.tabs.insert(to, tab);
// Re-derive the active index so the same logical tab stays selected:
// removal shifts indices after `from` left, insertion shifts indices at
// or after `to` right.
self.active = if was_active == from {
to
} else {
let mut a = was_active;
if from < a {
a -= 1;
}
if to <= a {
a += 1;
}
a
};
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>,
) {
// The settings tab has a fixed label and isn't user-renamable.
if self.tabs.get(index).is_none_or(Tab::is_settings) {
return;
}
let current = self.tab_label(&self.tabs[index], index, 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();
}
/// 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);
self.focus_active(window, cx);
cx.notify();
}
// Cmd+19 (⌘ on macOS, Ctrl elsewhere) tab switching. New Tab / Close Tab /
// Toggle Theme are bound via the keymap (see `init`) so they share one path
// with the menu bar.
fn on_key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let m = &ev.keystroke.modifiers;
// Use the portable "secondary" modifier so this matches the keymap's
// `secondary-*` bindings. Reject the other platform-ish key (⌃ on macOS,
// Win/Super elsewhere) and Alt so only the bare secondary chord triggers.
let extra_platform = if cfg!(target_os = "macos") {
m.control
} else {
m.platform
};
if !m.secondary() || m.alt || extra_platform {
return;
}
// Cmd/Ctrl+1..9 → tabs 0..8 (the 0 key has no tab and is ignored).
if let Some(n @ 1..=9) = ev.keystroke.key.chars().next().and_then(|c| c.to_digit(10)) {
self.activate(n as usize - 1, window, cx);
}
}
// ----- 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> {
let mut commands = Command::base_commands();
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, cx);
commands.push(Command {
title: format!("Switch to Tab: {label}"),
kind: CommandKind::ActivateTab(i),
});
}
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;
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.
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();
}
/// 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::*;
match kind {
NewTab => self.new_tab(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),
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => window.toggle_fullscreen(),
ResetFontSize => self.reset_font_size(cx),
FindInTerminal => {
// Open the search bar on the pane focus just returned to (the
// palette closed before we got here, restoring terminal focus).
if let Some(leaf) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.open_search(window, cx));
}
}
ClearTerminal => {
// Same focus story as FindInTerminal: act on the pane the closing
// palette just handed focus back to.
if let Some(leaf) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.clear_scrollback(cx));
}
}
ReopenClosedTab => self.reopen_closed_tab(window, cx),
OpenSettings => self.toggle_settings(window, cx),
RestartDaemon => self.restart_daemon(window, cx),
SetTheme(i) => {
if let Some(preset) = crate::ui::presets::all().get(i) {
self.set_preset(preset.id, window, cx);
}
}
// Handled inside `PaletteView` (opens the theme sub-list); it never
// emits a `Confirm` for this variant, so it never reaches here.
OpenThemePicker => {}
ActivateTab(i) => self.activate(i, window, cx),
}
}
// ----- Settings tab (Cmd+,) -------------------------------------------
/// Index of the dedicated settings tab, if one is open.
fn settings_tab_index(&self) -> Option<usize> {
self.tabs.iter().position(Tab::is_settings)
}
/// Open the settings tab (Cmd+,). If one already exists, just activate it;
/// otherwise assemble a new tab from the per-widget builders below (each
/// pre-filled from config, with its subscriptions pushed onto `subs`) and
/// focus it.
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.settings_tab_index() {
self.activate(index, window, cx);
return;
}
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 color_pickers = self.build_color_pickers(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
self.maximized = None;
self.tabs.push(Tab {
pane: Pane::Empty,
name: Some("Settings".to_string()),
settings: Some(SettingsState {
focus_handle: focus_handle.clone(),
section: SettingsSection::Appearance,
font_select,
font_bold_select,
font_italic_select,
shell_program_input,
shell_args_input,
wd_path_input,
color_pickers,
colors_expanded: false,
scroll_slider,
_subs: subs,
}),
});
self.active = self.tabs.len() - 1;
window.focus(&focus_handle, cx);
self.save_session(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)
}
/// One color picker per overridable neutral, seeded with the effective
/// current color (override if set, else the active preset's default) so the
/// swatch shows what's actually on screen. Each `Change` writes the override
/// and re-applies the theme live.
fn build_color_pickers(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<(ColorKey, Entity<ColorPickerState>)> {
// Snapshot what the pickers need now and drop the `cfg` borrow, so the
// `cx.new` / `cx.subscribe_in` calls below can borrow `cx` mutably.
let cfg = cx.global::<Config>();
let theme_preset = cfg.theme_preset.clone();
let colors = cfg.colors.clone();
let neutrals = crate::ui::presets::by_id(&theme_preset).neutrals();
ColorKey::ALL
.iter()
.map(|&key| {
let effective = color_or(key.get(&colors), key.default_u32(&neutrals));
let state = cx.new(|cx| ColorPickerState::new(window, cx).default_value(effective));
subs.push(cx.subscribe_in(
&state,
window,
move |this, _picker, ev: &ColorPickerEvent, window, cx| {
let ColorPickerEvent::Change(value) = ev;
this.set_color_override(key, *value, window, cx);
},
));
(key, state)
})
.collect()
}
/// 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 tab (Esc inside the panel).
pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.settings_tab_index() {
self.close_tab(index, 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, cx: &mut Context<Self>) {
let (font_size, line_height, font_family) = {
let cfg = cx.global::<Config>();
(cfg.font_size, cfg.line_height, cfg.font_family.clone())
};
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));
}
}
}
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));
}
}
}
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
.settings_tab_index()
.and_then(|i| self.tabs[i].settings.as_ref())
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
.settings_tab_index()
.and_then(|i| self.tabs[i].settings.as_ref())
.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.
pub(crate) fn active_settings(&self) -> Option<&SettingsState> {
self.tabs.get(self.active).and_then(|t| t.settings.as_ref())
}
/// Select a sidebar section in the active settings tab (no-op elsewhere).
pub(crate) fn select_settings_section(
&mut self,
target: SettingsSection,
cx: &mut Context<Self>,
) {
if let Some(s) = self
.tabs
.get_mut(self.active)
.and_then(|t| t.settings.as_mut())
{
s.section = target;
}
cx.notify();
}
/// Expand/collapse the Colors override group in the settings tab's
/// Appearance section (no-op elsewhere).
pub(crate) fn toggle_settings_colors(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self
.tabs
.get_mut(self.active)
.and_then(|t| t.settings.as_mut())
{
s.colors_expanded = !s.colors_expanded;
}
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 {
let strip = self.tab_strip(window, 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(),
// The settings tab renders its panel instead of a terminal pane.
Some(tab) if tab.is_settings() => self.render_settings(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)
}
}
}
};
div()
.id("tty7-root")
.size_full()
.flex()
.flex_col()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.on_key_down(cx.listener(Self::on_key_down))
.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, _: &CloseActiveTab, 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, _: &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, _: &OpenSettings, window, cx| this.toggle_settings(window, cx)),
)
.on_action(
cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_daemon(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()))
.child(
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`, which is applied after its
// own `.h(TITLE_BAR_HEIGHT)`, so this override wins.
.h(px(40.))
.bg(cx.theme().transparent)
.border_color(cx.theme().transparent)
// The tab strip anchors left; the title bar keeps its right
// edge clear (before the traffic lights' mirror gap on macOS).
.child(strip),
)
.child(div().flex_1().relative().overflow_hidden().child(body))
// Command palette overlay, layered above everything when open.
.when_some(self.palette.clone(), |this, palette| this.child(palette))
}
}
/// 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),
}
}
/// 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 {
cwd: view.cwd(),
pane_id: Some(view.pane_id),
}
}
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,
},
}
}
/// 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.
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::new`) 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 {
let pane = session_to_pane(&st.pane, &alive, font_size, window, cx);
tabs.push(Tab {
pane,
name: st.name.clone(),
settings: None,
});
}
// Clamp the saved active index into the rebuilt range.
let active = session.active.min(tabs.len() - 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.
fn session_to_pane(
sp: &SessionPane,
alive: &std::collections::HashSet<u64>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Pane {
match sp {
SessionPane::Leaf { cwd, pane_id } => {
// 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 shell pick isn't persisted in the session, so a stale pane that
// must respawn comes back on the default shell.
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
Pane::leaf(view)
}
SessionPane::Split { axis, ratio, a, b } => {
let axis = match axis {
SessionAxis::Horizontal => Axis::Horizontal,
SessionAxis::Vertical => Axis::Vertical,
};
let a = session_to_pane(a, alive, font_size, window, cx);
let b = session_to_pane(b, alive, font_size, window, cx);
Pane::split_node(axis, *ratio, a, b)
}
}
}
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>,
) -> Entity<TerminalView> {
let view = cx.new(|cx| {
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
.expect("failed to start terminal");
// 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();
view
}