mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-27 00:02:36 +00:00
Add SelectNextTab / SelectPrevTab, which move to the neighbouring tab in the order the strip or sidebar shows them, wrapping, with no popup. Defaults: Cmd+Shift+] / Cmd+Shift+[ on macOS, Ctrl+PgDn / Ctrl+PgUp elsewhere. The tmux preset's prefix n / p now bind these (tmux next-window semantics) instead of the MRU switcher, which never auto-committed there. NextTab / PrevTab keep their config names and Ctrl+Tab, but are labelled Recent Tab Switcher on the Keybindings page; the palette's Next/Previous Tab entries now show the SelectNextTab chord they actually run.
12360 lines
489 KiB
Rust
12360 lines
489 KiB
Rust
use gpui::{
|
|
App, Axis, Bounds, Context, Edges, Entity, Focusable, Pixels, PromptLevel, Size, Subscription,
|
|
Window, div, img, point, prelude::*, px, size,
|
|
};
|
|
use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
|
|
use gpui_component::input::{InputEvent, InputState};
|
|
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
|
|
use gpui_component::slider::{SliderEvent, SliderState};
|
|
use gpui_component::{
|
|
ActiveTheme as _, IndexPath, InteractiveElementExt as _, TitleBar, WindowExt as _,
|
|
};
|
|
use std::cell::{Cell, RefCell};
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
|
|
use crate::core::actions::*;
|
|
use crate::core::config::{
|
|
Config, CursorStyle as ConfigCursorStyle, MouseZoomModifier, NewTabPosition, RightPanelTab,
|
|
ShellConfig, TabBarPosition, WindowBackdrop,
|
|
};
|
|
use crate::core::session::{
|
|
Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore,
|
|
};
|
|
use crate::core::shells::ShellInventory;
|
|
use crate::core::ssh_config;
|
|
use crate::core::window_state::{WindowGeometry as _, WindowState};
|
|
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
|
|
use crate::daemon::spawn::DaemonMismatch;
|
|
use crate::terminal::view::{ChildExited, TerminalView};
|
|
use crate::ui::forwards::{ForwardFields, added_forward, rule_of};
|
|
use crate::ui::host_registry::HostId;
|
|
use crate::ui::i18n::{L10nKey, set_locale, t, t_fmt, t_plural};
|
|
use crate::ui::palette::{
|
|
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
|
|
};
|
|
use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot};
|
|
use crate::ui::presets::Fill;
|
|
use crate::ui::scm::ScmIntent;
|
|
use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor};
|
|
use crate::ui::theme::{apply_theme, set_menus};
|
|
|
|
/// What to start in a pane that is about to be opened.
|
|
pub(crate) enum SpawnAs {
|
|
/// A local shell; `None` is whatever the default one is.
|
|
Shell(Option<ShellSpec>),
|
|
Ssh(Box<crate::daemon::protocol::NativeSshSpec>),
|
|
}
|
|
|
|
/// Where a row taken out of the new-tab menu lands.
|
|
///
|
|
/// Windows Terminal's rule, and the reason this is a parameter rather than two
|
|
/// menus: the same list of shells and hosts serves both, and ⌥ at click time
|
|
/// picks between them.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum SpawnWhere {
|
|
NewTab,
|
|
/// Beside the pane in front of the user, on [`Axis::Horizontal`] — the
|
|
/// side `SplitRight` already puts a pane on. Deliberately not Windows
|
|
/// Terminal's aspect-ratio guess: a split that lands somewhere different
|
|
/// depending on the shape of the pane cannot be aimed.
|
|
Split,
|
|
}
|
|
|
|
impl SpawnWhere {
|
|
/// Read off the live keyboard state at click time. The menu hands its
|
|
/// handler a synthetic click with no modifiers on it, so there is nowhere
|
|
/// else the ⌥ the user is holding can be found.
|
|
pub(crate) fn from_modifiers(mods: gpui::Modifiers) -> Self {
|
|
if mods.alt {
|
|
SpawnWhere::Split
|
|
} else {
|
|
SpawnWhere::NewTab
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ThemeEdit {
|
|
Background,
|
|
Foreground,
|
|
Accent,
|
|
Cursor,
|
|
Selection,
|
|
Ansi(usize),
|
|
}
|
|
|
|
/// The 16 ANSI slots in the order a terminal numbers them: 0-7, then their
|
|
/// bright twins. A theme author needs to know that slot 9 is what `\e[91m`
|
|
/// paints — "Color 9" does not say that, and "Bright red" does.
|
|
const ANSI_COLOR_LABELS: [L10nKey; 16] = [
|
|
L10nKey::AppThemeAnsiBlack,
|
|
L10nKey::AppThemeAnsiRed,
|
|
L10nKey::AppThemeAnsiGreen,
|
|
L10nKey::AppThemeAnsiYellow,
|
|
L10nKey::AppThemeAnsiBlue,
|
|
L10nKey::AppThemeAnsiMagenta,
|
|
L10nKey::AppThemeAnsiCyan,
|
|
L10nKey::AppThemeAnsiWhite,
|
|
L10nKey::AppThemeAnsiBrightBlack,
|
|
L10nKey::AppThemeAnsiBrightRed,
|
|
L10nKey::AppThemeAnsiBrightGreen,
|
|
L10nKey::AppThemeAnsiBrightYellow,
|
|
L10nKey::AppThemeAnsiBrightBlue,
|
|
L10nKey::AppThemeAnsiBrightMagenta,
|
|
L10nKey::AppThemeAnsiBrightCyan,
|
|
L10nKey::AppThemeAnsiBrightWhite,
|
|
];
|
|
|
|
/// Looked up at render time, never stored: the editor outlives a language
|
|
/// change, and a label cached when it opened would stay in the old language.
|
|
pub(crate) fn theme_edit_label(edit: ThemeEdit) -> &'static str {
|
|
t(match edit {
|
|
ThemeEdit::Background => L10nKey::AppThemeColorBackground,
|
|
ThemeEdit::Foreground => L10nKey::AppThemeColorForeground,
|
|
ThemeEdit::Accent => L10nKey::AppThemeColorAccent,
|
|
ThemeEdit::Cursor => L10nKey::AppThemeColorCursor,
|
|
ThemeEdit::Selection => L10nKey::AppThemeColorSelection,
|
|
ThemeEdit::Ansi(i) => ANSI_COLOR_LABELS[i.min(15)],
|
|
})
|
|
}
|
|
|
|
fn hsla_to_u32(color: gpui::Hsla) -> u32 {
|
|
let rgba: gpui::Rgba = color.into();
|
|
let to = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u32;
|
|
(to(rgba.r) << 16) | (to(rgba.g) << 8) | to(rgba.b)
|
|
}
|
|
|
|
// The steppers clamp to the same range `sanitize` allows, defined in
|
|
// tty7-core next to the validation: a local, narrower pair used to push a
|
|
// config-legal value the wrong way — `font_size: 50` shrank to 48 on "+",
|
|
// and the result was written back to the file (#550).
|
|
pub(crate) use crate::core::config::{
|
|
FONT_SIZE_MAX, FONT_SIZE_MIN, LINE_HEIGHT_MAX, LINE_HEIGHT_MIN,
|
|
};
|
|
|
|
pub(crate) const FONT_SIZE_STEP: f32 = 1.0;
|
|
|
|
pub(crate) const UI_FONT_SIZE_STEP: f32 = 1.0;
|
|
|
|
pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05;
|
|
|
|
const MAX_CLOSED_TABS: usize = 20;
|
|
|
|
const RESIZE_STEP: f32 = 0.05;
|
|
|
|
pub(crate) const RECORD_COMMIT_DELAY_MS: u64 = 650;
|
|
|
|
/// The narrowest the terminal column may be squeezed to by the panels beside
|
|
/// it: roughly forty columns at the default font, which is a prompt with room
|
|
/// to read what it printed.
|
|
pub(crate) const TERMINAL_MIN_W: f32 = 360.;
|
|
|
|
/// Half the period of the home page's cursor, near enough the terminal's own
|
|
/// 530ms that the two do not read as different clocks.
|
|
pub(crate) const HOME_CURSOR_BLINK_MS: u64 = 600;
|
|
|
|
/// How wide one side panel may grow, given the floor under the terminal and the
|
|
/// floor under the *other* panel.
|
|
///
|
|
/// Each panel used to cap itself at half the window and know nothing about the
|
|
/// other, so the two of them could take the whole of it between them — two
|
|
/// halves leave nothing. On a 720-point window with both open the terminal was
|
|
/// left about 260 points, twenty-odd columns of the one pane the window exists
|
|
/// to show, and dragging either panel wider took the difference out of it.
|
|
/// Reserving the terminal's floor and the other panel's floor first means the
|
|
/// pane the user came for is spoken for before either panel is, and the cap
|
|
/// that comes out of it bounds the drag and the layout alike — so a panel
|
|
/// dragged to its limit stays where it was dropped instead of springing back.
|
|
///
|
|
/// The half-window cap stays, and this takes whichever of the two binds harder.
|
|
/// It is the one that binds on a wide window, where the reservation alone would
|
|
/// have *raised* the ceiling — on a 1440-point window it works out to 864, so
|
|
/// dropping it would have let a panel grow past where it could before under
|
|
/// cover of a change that is only meant to take width away from panels.
|
|
///
|
|
/// `others_floor` is the sum of the floors under every *other* column that is
|
|
/// open — the panel opposite, and the document column when a file or a diff is
|
|
/// docked. It is zero for each of them that is closed, which is why this takes
|
|
/// floors rather than reading them: only the caller knows what is up.
|
|
pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, others_floor: f32) -> f32 {
|
|
(viewport - TERMINAL_MIN_W - others_floor)
|
|
.min(viewport * SIDE_PANEL_MAX_RATIO)
|
|
.max(own_floor)
|
|
}
|
|
|
|
/// The half of the window neither panel may grow past on its own.
|
|
const SIDE_PANEL_MAX_RATIO: f32 = 0.5;
|
|
|
|
/// The narrowest a docked document column may be squeezed to: the header, a
|
|
/// readable run of about thirty columns, and the status bar under them. Below
|
|
/// this a file is a ribbon of hyphenated fragments and the column is worth
|
|
/// less than the terminal width it costs.
|
|
pub(crate) const DOCUMENT_MIN_W: f32 = 280.;
|
|
|
|
/// How wide the docked document column is, given the width the terminal and the
|
|
/// document share — the window less the sidebar and the right panel — and the
|
|
/// share of it the user asked for.
|
|
///
|
|
/// `None` is the narrow-window answer: there is no way to give both the
|
|
/// terminal and a document a width worth reading, so the caller falls back to
|
|
/// filling the workspace for this frame. That fallback is *derived*, never
|
|
/// stored — widening the window docks again on the next frame, and the user's
|
|
/// saved `document_layout` is untouched throughout.
|
|
///
|
|
/// The named two-thirds deliberately runs past the half-window cap the side
|
|
/// panels obey. That cap is there so neither *panel* can dominate a wide
|
|
/// display; the document is the thing the user is reading, and an increment
|
|
/// that silently became a half on every window wider than about 720 points of
|
|
/// body would be a lie. The terminal's floor still binds.
|
|
pub(crate) fn document_column_px(body: f32, ratio: f32) -> Option<f32> {
|
|
if !body.is_finite() || body < TERMINAL_MIN_W + DOCUMENT_MIN_W {
|
|
return None;
|
|
}
|
|
let ratio = if ratio.is_finite() { ratio } else { 0.5 };
|
|
Some((body * ratio).clamp(DOCUMENT_MIN_W, body - TERMINAL_MIN_W))
|
|
}
|
|
|
|
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
|
|
|
|
pub(crate) const TILE_SIZE: f32 = 32.;
|
|
pub(crate) const TILE_GLYPH: f32 = 16.;
|
|
/// A tile that sits in a body row rather than in chrome: the box shrinks to
|
|
/// the minimum hit target, but the glyph keeps the chrome size. An 11px glyph
|
|
/// here read as a disabled ornament next to 14px text, and put a second,
|
|
/// smaller folder in the same column as the panel's folder tab.
|
|
pub(crate) const TILE_SIZE_SM: f32 = 24.;
|
|
pub(crate) const TILE_GLYPH_SM: f32 = TILE_GLYPH;
|
|
|
|
/// The tile that lives *inside* a list row rather than beside one, for the
|
|
/// buttons a row reveals on hover.
|
|
///
|
|
/// A box below [`TILE_SIZE_SM`] because of width: three `TILE_SIZE_SM` squares
|
|
/// would eat 72 of the 236px a file name has to live in, where three of these
|
|
/// eat 54.
|
|
pub(crate) const TILE_SIZE_XS: f32 = 18.;
|
|
pub(crate) const TILE_GLYPH_XS: f32 = 11.;
|
|
|
|
/// Line-only controls share the toolbar icon size.
|
|
pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
|
|
|
|
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
|
|
pub(crate) const TILE_PAD_SM: f32 = (TILE_SIZE_SM - TILE_GLYPH_SM) / 2.;
|
|
|
|
const DOCS_URL: &str = "https://github.com/l0ng-ai/tty7#readme";
|
|
const DISCORD_URL: &str = "https://discord.gg/s3dethqz2V";
|
|
const ISSUES_URL: &str = "https://github.com/l0ng-ai/tty7/issues/new";
|
|
|
|
pub(crate) const CONTENT_INSET: f32 = 12.;
|
|
|
|
const TILE_EDGE_GAP: f32 = 5.;
|
|
|
|
pub(crate) fn tile_trailing_inset() -> f32 {
|
|
(CONTENT_INSET - TILE_PAD).max(TILE_EDGE_GAP)
|
|
}
|
|
|
|
pub(crate) fn tile_trailing_inset_sm() -> f32 {
|
|
(CONTENT_INSET - TILE_PAD_SM).max(TILE_EDGE_GAP)
|
|
}
|
|
|
|
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
|
|
|
|
pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. };
|
|
|
|
pub(crate) fn title_bar_hug_offset() -> f32 {
|
|
if cfg!(target_os = "macos") {
|
|
0.
|
|
} else {
|
|
tile_trailing_inset() - TITLE_BAR_LEAD
|
|
}
|
|
}
|
|
|
|
/// The bounds to remember for reopening this window at the same place.
|
|
///
|
|
/// Under client-side decorations the window's *outer* rectangle is the whole
|
|
/// surface, shadow included, and both Linux backends put the shadow back on
|
|
/// their own once the window is up: Wayland's first sized configure adds the
|
|
/// inset to whatever geometry was asked for (`compute_outer_size`), and on X11
|
|
/// the inset travels as `_GTK_FRAME_EXTENTS`, which a window manager only
|
|
/// advertises — and gpui only turns CSD on for — when it honours those extents
|
|
/// by keeping the *visible* frame put. Save the outer rectangle either way and
|
|
/// it comes back twice the shadow larger every launch; that is the bug Zed
|
|
/// fixed in ca9cee85e1 ("linux: Fix non-maximized Zed windows growing larger
|
|
/// across sessions", #22301), whose own measurements were taken on X11.
|
|
///
|
|
/// So save the inner rectangle, exactly as Zed does, on every platform. It is
|
|
/// the visible frame, which is what the backends re-inflate back to, and it
|
|
/// costs nothing elsewhere: `inner_window_bounds` defaults to `window_bounds`
|
|
/// on the `PlatformWindow` trait, and neither the macOS nor the Windows backend
|
|
/// nor gpui's `TestWindow` overrides it. On X11 without a compositor the same
|
|
/// holds for a different reason — `window_decorations()` answers `Server`, the
|
|
/// window border never calls `set_client_inset`, and the insets stay zero.
|
|
fn window_bounds_to_remember(window: &Window) -> Bounds<Pixels> {
|
|
window.inner_window_bounds().get_bounds()
|
|
}
|
|
|
|
/// The band the tab strip claims for a drop, in the window's outer coordinates.
|
|
///
|
|
/// The strip is the top of the *content*, and under client-side decorations the
|
|
/// content sits inside the frame padding while `viewport` still measures the
|
|
/// whole surface, shadow included — so the band starts at the padding and stops
|
|
/// at the far edge of the frame, one padding in from each side. Anything else
|
|
/// leaves the outermost chips outside the zone that is supposed to contain
|
|
/// them. `window_paddings` answers `Edges::all(0)` under server-side
|
|
/// decorations, which is every platform but Linux CSD, so off Linux this stays
|
|
/// the full-width rectangle from the window's corner it has always been.
|
|
fn strip_band(viewport: Size<Pixels>, pad: Edges<Pixels>) -> Bounds<Pixels> {
|
|
Bounds {
|
|
origin: point(pad.left, pad.top),
|
|
size: size(
|
|
(viewport.width - pad.left - pad.right).max(px(0.)),
|
|
px(TITLE_BAR_HEIGHT),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(crate) const WINDOW_MARK_SIZE: f32 = 20.;
|
|
|
|
pub(crate) fn title_bar_drag(
|
|
row: gpui::Stateful<gpui::Div>,
|
|
key: &'static str,
|
|
window: &mut gpui::Window,
|
|
cx: &mut gpui::App,
|
|
) -> gpui::Stateful<gpui::Div> {
|
|
window_move_gesture(row, key, window, cx).on_double_click(|_, window, _| {
|
|
if cfg!(target_os = "linux") {
|
|
window.zoom_window();
|
|
} else {
|
|
window.titlebar_double_click();
|
|
}
|
|
})
|
|
}
|
|
|
|
pub(crate) struct WindowMoveArm {
|
|
should_move: bool,
|
|
}
|
|
|
|
pub(crate) fn window_move_gesture(
|
|
row: gpui::Stateful<gpui::Div>,
|
|
key: &'static str,
|
|
window: &mut gpui::Window,
|
|
cx: &mut gpui::App,
|
|
) -> gpui::Stateful<gpui::Div> {
|
|
let arm = window.use_keyed_state(key, cx, |_, _| WindowMoveArm { should_move: false });
|
|
row.window_control_area(gpui::WindowControlArea::Drag)
|
|
.on_mouse_down(
|
|
gpui::MouseButton::Left,
|
|
window.listener_for(&arm, |arm, _: &gpui::MouseDownEvent, _, _| {
|
|
arm.should_move = true;
|
|
}),
|
|
)
|
|
.on_mouse_down_out(
|
|
window.listener_for(&arm, |arm, _: &gpui::MouseDownEvent, _, _| {
|
|
arm.should_move = false;
|
|
}),
|
|
)
|
|
.on_mouse_up(
|
|
gpui::MouseButton::Left,
|
|
window.listener_for(&arm, |arm, _: &gpui::MouseUpEvent, _, _| {
|
|
arm.should_move = false;
|
|
}),
|
|
)
|
|
.on_mouse_up_out(
|
|
gpui::MouseButton::Left,
|
|
window.listener_for(&arm, |arm, _: &gpui::MouseUpEvent, _, _| {
|
|
arm.should_move = false;
|
|
}),
|
|
)
|
|
.on_mouse_move(
|
|
window.listener_for(&arm, |arm, _: &gpui::MouseMoveEvent, window, _| {
|
|
if arm.should_move {
|
|
arm.should_move = false;
|
|
window.start_window_move();
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn window_mark() -> Option<impl IntoElement> {
|
|
if cfg!(target_os = "macos") {
|
|
return None;
|
|
}
|
|
static LOGO: std::sync::OnceLock<Arc<gpui::Image>> = std::sync::OnceLock::new();
|
|
let logo = LOGO
|
|
.get_or_init(|| {
|
|
Arc::new(gpui::Image::from_bytes(
|
|
gpui::ImageFormat::Png,
|
|
include_bytes!("../../assets/logo@256.png").to_vec(),
|
|
))
|
|
})
|
|
.clone();
|
|
Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0())
|
|
}
|
|
|
|
pub struct Tab {
|
|
pub pane: Pane,
|
|
pub name: Option<String>,
|
|
last_focused: Option<gpui::EntityId>,
|
|
/// The pane zoomed in this tab, stashed here by `activate` while another
|
|
/// tab is on screen — zoom is a tab's view state, not the window's, so
|
|
/// looking at another tab and coming back must not lose it (#599). `None`
|
|
/// while the tab is active: then the zoom lives in `Tty7App::maximized`.
|
|
pub(crate) zoomed: Option<Entity<TerminalView>>,
|
|
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
|
|
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
|
|
pub(crate) sidebar_group: std::cell::RefCell<Option<crate::core::group_key::GroupKey>>,
|
|
pub(crate) overlay_top: OverlayTop,
|
|
/// Whether this tab's document fills the workspace or docks beside the
|
|
/// terminal, once the tab has been told. `None` follows `document_layout`
|
|
/// in the config, which is the default a fresh tab starts from and the
|
|
/// last explicit choice anyone made.
|
|
///
|
|
/// Per tab rather than per window because what you are doing differs per
|
|
/// tab: reading a long file in one while an agent works in another wants
|
|
/// the whole window here and half of it there, and a global switch made
|
|
/// each of those flip the other.
|
|
pub(crate) document_layout: Option<crate::core::config::DocumentLayout>,
|
|
pub(crate) tree_id: std::cell::Cell<tty7_core::core::machine::TabId>,
|
|
/// Monotonic stamp of when this tab was last activated, used to order the
|
|
/// switcher's tab column most-recently-used first. Zero means never.
|
|
pub(crate) last_used: std::cell::Cell<u64>,
|
|
/// Where a directional focus move started, keyed by the pane it landed on
|
|
/// and the direction that undoes it, so reversing a move comes back here
|
|
/// instead of wherever geometry ranks first (#738). Per tab because the
|
|
/// panes are.
|
|
///
|
|
/// Keyed by pane and not by direction alone: one slot per direction is
|
|
/// overwritten by the next move the same way, so a walk of two steps left
|
|
/// and two back right ends somewhere other than it started — the very drift
|
|
/// this is here to stop. A pane remembering its own way in retraces the
|
|
/// whole walk.
|
|
///
|
|
/// A recorded pane only ever breaks a tie between the panes already next to
|
|
/// the one focus is leaving, so an entry left over from an older layout — or
|
|
/// from before a click moved focus somewhere else entirely — can at worst
|
|
/// pick a different neighbour, never a distant one. Entries naming a pane
|
|
/// the tab no longer holds are dropped as the next one is written, so a
|
|
/// closed pane leaves nothing behind either.
|
|
focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub(crate) enum OverlayTop {
|
|
#[default]
|
|
Code,
|
|
Diff,
|
|
}
|
|
|
|
impl Tab {
|
|
pub(crate) fn new(pane: Pane) -> Self {
|
|
Self {
|
|
pane,
|
|
name: None,
|
|
last_focused: None,
|
|
zoomed: None,
|
|
diff_overlay: None,
|
|
code: None,
|
|
overlay_top: OverlayTop::default(),
|
|
document_layout: None,
|
|
sidebar_group: std::cell::RefCell::new(None),
|
|
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
|
last_used: std::cell::Cell::new(0),
|
|
focus_origin: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self {
|
|
Self {
|
|
pane,
|
|
name: tree.name.clone(),
|
|
last_focused: None,
|
|
zoomed: None,
|
|
diff_overlay: None,
|
|
code: None,
|
|
overlay_top: OverlayTop::default(),
|
|
document_layout: None,
|
|
sidebar_group: std::cell::RefCell::new(
|
|
tree.sidebar_group
|
|
.as_deref()
|
|
.and_then(crate::core::group_key::GroupKey::decode),
|
|
),
|
|
tree_id: std::cell::Cell::new(tree.id),
|
|
last_used: std::cell::Cell::new(0),
|
|
focus_origin: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> {
|
|
match self.last_focused {
|
|
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
|
|
None => self.pane.first_leaf(),
|
|
}
|
|
}
|
|
|
|
/// The pane a move in `dir` out of `at` should return to, if it reverses
|
|
/// the move that brought focus to `at`.
|
|
fn focus_origin(&self, at: gpui::EntityId, dir: Dir) -> Option<gpui::EntityId> {
|
|
self.focus_origin.get(&(at, dir)).copied()
|
|
}
|
|
|
|
/// Remember that a move in `dir` carried focus from `from` to `to`, so the
|
|
/// move back out of `to` returns. `live` names the panes the tab holds now:
|
|
/// anything the layout has moved on from is forgotten here rather than
|
|
/// accumulating for the life of the window.
|
|
fn remember_focus_origin(
|
|
&mut self,
|
|
from: gpui::EntityId,
|
|
to: gpui::EntityId,
|
|
dir: Dir,
|
|
live: &[gpui::EntityId],
|
|
) {
|
|
self.focus_origin
|
|
.retain(|(at, _), origin| live.contains(at) && live.contains(origin));
|
|
self.focus_origin.insert((to, dir.opposite()), from);
|
|
}
|
|
|
|
pub(crate) fn detail_pane(
|
|
&self,
|
|
window: &Window,
|
|
cx: &gpui::App,
|
|
) -> Option<Entity<TerminalView>> {
|
|
self.pane
|
|
.focused_leaf(window, cx)
|
|
.or_else(|| self.focus_target())
|
|
.and_then(|slot| slot.terminal().cloned())
|
|
}
|
|
|
|
/// The leaf a tab names itself after — its title and, with it, the home
|
|
/// that title's path is measured against.
|
|
fn title_leaf(&self, window: Option<&Window>, cx: &App) -> Option<Entity<TerminalView>> {
|
|
let leaf = match window {
|
|
Some(window) => self
|
|
.pane
|
|
.focused_leaf(window, cx)
|
|
.or_else(|| self.focus_target()),
|
|
None => self.focus_target(),
|
|
};
|
|
leaf.and_then(|l| l.terminal().cloned())
|
|
}
|
|
|
|
pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String {
|
|
self.title_leaf(window, cx)
|
|
.map(|l| l.read(cx).title.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// [`Self::leaf_title`] together with what a `~` in it would mean — one
|
|
/// leaf lookup, so the title and the home shortening it can never come
|
|
/// from different panes (#580).
|
|
pub(crate) fn leaf_title_and_home(
|
|
&self,
|
|
window: Option<&Window>,
|
|
cx: &App,
|
|
) -> (String, Option<std::path::PathBuf>) {
|
|
let Some(leaf) = self.title_leaf(window, cx) else {
|
|
return (String::new(), None);
|
|
};
|
|
let leaf = leaf.read(cx);
|
|
(leaf.title.clone(), leaf.display_home(cx))
|
|
}
|
|
|
|
/// This tab as the shared label ladder reads it, together with what a `~`
|
|
/// in whatever it ends up named would mean.
|
|
///
|
|
/// [`TabView`](tty7_core::core::tab_view::TabView) is how a tab looks to
|
|
/// someone who is *not* the window showing it — the switcher listing
|
|
/// another window's workspace, `tty7 tab ls` on the far side of a socket.
|
|
/// Building one here from the live pane is what stops this window having a
|
|
/// second opinion: both sides then rank a given name, a title, an agent and
|
|
/// a directory through
|
|
/// [`TabView::label`](tty7_core::core::tab_view::TabView::label), so the
|
|
/// strip's answer to "which repo is this?" is the switcher's answer too.
|
|
///
|
|
/// Everything comes off the one leaf the tab names itself after, so the
|
|
/// title and the directory standing in for it can never describe different
|
|
/// panes (#580).
|
|
pub(crate) fn label_view(
|
|
&self,
|
|
window: Option<&Window>,
|
|
cx: &App,
|
|
) -> (
|
|
tty7_core::core::tab_view::TabView,
|
|
Option<std::path::PathBuf>,
|
|
) {
|
|
let name = self.name.clone();
|
|
let Some(leaf) = self.title_leaf(window, cx) else {
|
|
return (
|
|
tty7_core::core::tab_view::TabView {
|
|
id: self.tree_id.get(),
|
|
name,
|
|
title: String::new(),
|
|
osc_title: None,
|
|
cwd: None,
|
|
agent: None,
|
|
status: None,
|
|
live: false,
|
|
panes: 0,
|
|
},
|
|
None,
|
|
);
|
|
};
|
|
let leaf = leaf.read(cx);
|
|
let view = tty7_core::core::tab_view::TabView {
|
|
id: self.tree_id.get(),
|
|
name,
|
|
// The tree's `title` is the foreground process name — what it falls
|
|
// back on once a pane has said nothing about itself. A live pane's
|
|
// equivalent is the placeholder it answers to unprompted: any
|
|
// *other* default it was given (an SSH host, a workspace name) is a
|
|
// name tty7 chose for it deliberately, and `stated_title` hands
|
|
// those up as the title the pane is showing.
|
|
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
|
|
osc_title: leaf.stated_title().map(str::to_string),
|
|
cwd: leaf.cwd().map(|p| p.display().to_string()),
|
|
agent: leaf.agent(),
|
|
status: leaf.agent_session().map(|s| s.status),
|
|
live: !leaf.terminal.exited,
|
|
panes: self.pane.terminals().len(),
|
|
};
|
|
(view, leaf.display_home(cx))
|
|
}
|
|
|
|
pub(crate) fn git_status(
|
|
&self,
|
|
window: Option<&Window>,
|
|
cx: &App,
|
|
) -> Option<crate::terminal::git_status::GitStatus> {
|
|
let leaf = match window {
|
|
Some(window) => self.pane.focused_or_first(window, cx),
|
|
None => self.pane.first_leaf().and_then(|s| s.terminal().cloned()),
|
|
}?;
|
|
leaf.read(cx).git_status(cx)
|
|
}
|
|
|
|
pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> {
|
|
self.pane
|
|
.terminals()
|
|
.into_iter()
|
|
.find_map(|l| l.read(cx).agent())
|
|
}
|
|
|
|
/// The tab's most urgent agent leaf, named and reported by that one leaf.
|
|
///
|
|
/// `agent` and `agent_status` answer independently — the *first* leaf
|
|
/// carrying an agent, and the highest urgency found *anywhere* in the tab
|
|
/// — so reading them as a pair can put one pane's name beside another
|
|
/// pane's state, a row no leaf ever had. Anywhere both halves are shown
|
|
/// at once reads them from here instead (#543).
|
|
pub(crate) fn agent_row(
|
|
&self,
|
|
cx: &App,
|
|
) -> Option<(
|
|
crate::core::cli_agent::CLIAgent,
|
|
crate::core::cli_agent::AgentStatus,
|
|
)> {
|
|
use crate::core::cli_agent::AgentStatus;
|
|
let urgency = |s: AgentStatus| match s {
|
|
AgentStatus::Waiting => 3,
|
|
AgentStatus::Working => 2,
|
|
AgentStatus::Done => 1,
|
|
AgentStatus::Idle => 0,
|
|
};
|
|
self.pane
|
|
.terminals()
|
|
.into_iter()
|
|
.filter_map(|l| {
|
|
let view = l.read(cx);
|
|
let agent = view.agent()?;
|
|
// A pane whose agent is running but has never reported a
|
|
// session reads as idle, the same reading the badge has always
|
|
// given it.
|
|
let status = view
|
|
.agent_session()
|
|
.map(|s| s.status)
|
|
.unwrap_or(AgentStatus::Idle);
|
|
Some((agent, status))
|
|
})
|
|
.max_by_key(|(_, status)| urgency(*status))
|
|
}
|
|
|
|
pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> {
|
|
self.agent_row(cx).map(|(_, status)| status)
|
|
}
|
|
|
|
pub(crate) fn agent_unread_count(&self, cx: &App) -> usize {
|
|
use crate::core::cli_agent::AgentStatus;
|
|
if self.agent_status(cx) != Some(AgentStatus::Done) {
|
|
return 0;
|
|
}
|
|
self.pane
|
|
.terminals()
|
|
.into_iter()
|
|
.filter(|l| {
|
|
let v = l.read(cx);
|
|
v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
|
|
&& v.agent_result_unread()
|
|
})
|
|
.count()
|
|
}
|
|
}
|
|
|
|
/// What a rename box's contents mean for the tab's name.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Rename {
|
|
/// The box still holds what it was seeded with, so nothing was asked for.
|
|
Unchanged,
|
|
/// Emptied on purpose: the tab goes back to following its pane.
|
|
Cleared,
|
|
/// A name the user typed.
|
|
Named(String),
|
|
}
|
|
|
|
/// Read a rename box against the label it was seeded with.
|
|
///
|
|
/// The box is prefilled with the label as rendered, so it is never empty when
|
|
/// it opens and a commit cannot tell "left alone" from "typed the same thing"
|
|
/// by looking at the value alone. `Blur` commits as readily as Enter does, so
|
|
/// without the comparison, opening the box and clicking away stored the label
|
|
/// as a name — and a name is a different thing from the title it was copied
|
|
/// from: it stops following the pane, freezing the tab on whatever it happened
|
|
/// to say at that moment, with no way to undo it.
|
|
fn rename_outcome(value: &str, prefill: &str) -> Rename {
|
|
let value = value.trim();
|
|
match value {
|
|
v if v == prefill.trim() => Rename::Unchanged,
|
|
"" => Rename::Cleared,
|
|
v => Rename::Named(v.to_string()),
|
|
}
|
|
}
|
|
|
|
pub(crate) struct Renaming {
|
|
/// The tab being renamed, by tree id rather than index: an index drifts
|
|
/// the moment any other tab closes or the strip reorders, which used to
|
|
/// force every unrelated tab event to throw the half-typed name away —
|
|
/// and left a window where the commit landed on the wrong tab (#598).
|
|
pub(crate) tab: tty7_core::core::machine::TabId,
|
|
pub(crate) input: Entity<InputState>,
|
|
/// What the box was prefilled with, so a commit can tell an untouched box
|
|
/// from a typed one. The box is seeded with the label already on screen,
|
|
/// which means it is never empty and every commit would otherwise store a
|
|
/// name — including the ones the user never typed.
|
|
prefill: String,
|
|
_subs: Vec<Subscription>,
|
|
}
|
|
|
|
pub(crate) struct WorkspaceRename {
|
|
pub(crate) input: Entity<InputState>,
|
|
_subs: Vec<Subscription>,
|
|
}
|
|
|
|
pub(crate) struct GroupRename {
|
|
/// The group being renamed, by the key it had when the box opened.
|
|
///
|
|
/// A custom group *is* its name — there is no group record anywhere for
|
|
/// an id to point at, only the tabs that claim it. So renaming one means
|
|
/// rewriting every tab that says the old name, and this is what says
|
|
/// which those are.
|
|
pub(crate) key: crate::core::group_key::GroupKey,
|
|
pub(crate) input: Entity<InputState>,
|
|
pub(crate) _subs: Vec<Subscription>,
|
|
}
|
|
|
|
pub(crate) struct LoopbackForwardPanelState {
|
|
pub(crate) form_pane_id: Option<u64>,
|
|
pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>,
|
|
pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind,
|
|
pub(crate) mf_bind_host: Entity<InputState>,
|
|
pub(crate) mf_bind_port: Entity<InputState>,
|
|
pub(crate) mf_target_host: Entity<InputState>,
|
|
pub(crate) mf_target_port: Entity<InputState>,
|
|
pub(crate) mf_description: Entity<InputState>,
|
|
/// The rule the form is editing, whole rather than by id: an edit that
|
|
/// fails has to be able to put back what it took out, and the id alone
|
|
/// cannot describe the rule it named.
|
|
pub(crate) mf_editing: Option<crate::daemon::protocol::ManagedForward>,
|
|
/// Why the last Add or Save did not take, in the far side's own words.
|
|
/// Cleared the moment the form is closed or the edit is abandoned.
|
|
pub(crate) mf_error: Option<String>,
|
|
/// Return, on each of the boxes. Held here for the same reason the sftp
|
|
/// form holds its own: a live subscription on a box nothing is showing
|
|
/// would answer Return for a form that is gone.
|
|
pub(crate) mf_subs: Vec<Subscription>,
|
|
/// Whether the form is showing all five fields rather than the one.
|
|
///
|
|
/// Almost every forward anyone builds by hand is "bring the remote's :3000
|
|
/// over here", which is one number — and asking for five fields to collect
|
|
/// one number is what made the panel feel like paperwork. The rest of the
|
|
/// `ssh -L` grammar is still here, one disclosure away, for the forwards
|
|
/// that really do need it.
|
|
pub(crate) mf_advanced: bool,
|
|
}
|
|
|
|
pub struct Tty7App {
|
|
pub(crate) tabs: Vec<Tab>,
|
|
pub(crate) active: usize,
|
|
/// Hands out `Tab::last_used` stamps. A counter rather than a clock so two
|
|
/// activations in the same second still order.
|
|
tab_use_seq: std::cell::Cell<u64>,
|
|
/// A tab asked for before its workspace finished hydrating.
|
|
pending_tab: Option<tty7_core::core::machine::TabId>,
|
|
pub(crate) font_size: f32,
|
|
pub(crate) line_height: f32,
|
|
pub(crate) font_family: String,
|
|
pub(crate) font_family_bold: Option<String>,
|
|
pub(crate) font_family_italic: Option<String>,
|
|
pub(crate) font_fallbacks: Vec<String>,
|
|
pub(crate) font_features: Option<gpui::FontFeatures>,
|
|
terminal_cursor_style: ConfigCursorStyle,
|
|
terminal_scrollback_limit: usize,
|
|
_config_watch: Subscription,
|
|
_keystroke_watch: Subscription,
|
|
_activation_watch: Subscription,
|
|
_git_status_watch: Subscription,
|
|
_pane_liveness_watch: Subscription,
|
|
_appearance_watch: Subscription,
|
|
palette: Option<Entity<PaletteView>>,
|
|
palette_sub: Option<Subscription>,
|
|
/// Preset that was live when the palette's theme picker started previewing.
|
|
/// `Some` means the theme on screen is a preview that was never written to
|
|
/// disk, and closing the palette without confirming puts this one back.
|
|
theme_preview_restore: Option<String>,
|
|
pub(crate) closed: Vec<SessionTab>,
|
|
pub(crate) renaming: Option<Renaming>,
|
|
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
|
|
pub(crate) maximized: Option<Entity<TerminalView>>,
|
|
pub(crate) mod_hint_badges: bool,
|
|
pub(crate) mod_hint_gen: u64,
|
|
pub(crate) record_gen: u64,
|
|
pub(crate) home_focus: gpui::FocusHandle,
|
|
/// Whether the home page's cursor block is on this half-second.
|
|
///
|
|
/// A `bool` a timer flips, not a per-frame animation — the same shape the
|
|
/// terminal's own cursor uses. `with_animation(...).repeat()` asks for a
|
|
/// frame sixty times a second to change one glyph's opacity twice, and the
|
|
/// home page is otherwise perfectly still: it cost more than a live
|
|
/// terminal did, on a window with nothing open in it.
|
|
pub(crate) home_cursor_on: bool,
|
|
pub(crate) shells: ShellInventory,
|
|
pub(crate) shells_host: HostId,
|
|
pub(crate) loopback_panel: LoopbackForwardPanelState,
|
|
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
|
|
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
|
|
pub(crate) scm: crate::ui::scm::ScmPanelState,
|
|
pub(crate) diff_probes_inflight:
|
|
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
|
|
pub(crate) diff_probes_restale:
|
|
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
|
|
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
|
|
pub(crate) editor: crate::ui::code_editor::EditorPanelState,
|
|
pub(crate) sidebar_width: Rc<Cell<f32>>,
|
|
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
|
|
/// Whether the pointer is over the sidebar and over the tab strip. The
|
|
/// chrome tiles in each — new tab, the panel toggles, the app menu — are
|
|
/// drawn only while its own flag is set, so a window nobody is pointing at
|
|
/// carries no buttons at all. The right panel's own title bar is the
|
|
/// exception: its tiles are always painted while the panel is open.
|
|
/// How much width a settings row will actually get, measured once per
|
|
/// render. `settings_row` is called from page builders that never see the
|
|
/// window, and the answer differs per page — the SSH page spends a host
|
|
/// list on top of the nav before the row gets anything.
|
|
pub(crate) settings_row_width: Cell<f32>,
|
|
/// The window width the settings chrome sized itself against, measured in
|
|
/// the same pass. The pages that render their own chrome — the SSH host
|
|
/// list, the theme panel — are as blind to the window as `settings_row` is.
|
|
pub(crate) settings_viewport_w: Cell<f32>,
|
|
/// Cleared at the top of every settings render, then set by the first row
|
|
/// the live search matched, so exactly one row per page carries the anchor
|
|
/// the page scrolls to.
|
|
pub(crate) settings_hit_anchored: Cell<bool>,
|
|
last_settings_location: (SettingsSection, gpui::Point<gpui::Pixels>),
|
|
pub(crate) right_panel_width: Rc<Cell<f32>>,
|
|
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
|
|
/// The docked document column's share of the terminal column, live. Held
|
|
/// beside the config value rather than in it for the same reason the two
|
|
/// panel widths are: a drag writes this cell on every mouse move and the
|
|
/// config once, on mouse up.
|
|
pub(crate) document_ratio: Rc<Cell<f32>>,
|
|
pub(crate) document_dragging: Rc<Cell<bool>>,
|
|
pub(crate) right_panel_visible: bool,
|
|
pub(crate) right_panel_tab: RightPanelTab,
|
|
pub(crate) sidebar_collapsed: bool,
|
|
pub(crate) sidebar_scroll: gpui::ScrollHandle,
|
|
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
|
|
/// The pane the pointer is over, so only that one offers its drag handle.
|
|
pub(crate) pane_hover: Rc<Cell<Option<gpui::EntityId>>>,
|
|
pub(crate) pane_drag: crate::ui::pane_drag::PaneDragState,
|
|
/// Where a tab held over the layout would be grafted in, as the last
|
|
/// painted frame read it. The same bargain the pane drag's landing keeps:
|
|
/// offered only once the tree agrees it changes something, so releasing
|
|
/// over a highlight always does what the highlight showed.
|
|
pub(crate) tab_merge: Cell<
|
|
Option<(
|
|
tty7_core::core::machine::TabId,
|
|
crate::ui::pane_drag::DropZone<gpui::EntityId>,
|
|
)>,
|
|
>,
|
|
/// The pane a drag would put down as a tab of its own, and where among the
|
|
/// tabs it would go. Read back a frame later by the drop, like the two
|
|
/// landings above.
|
|
pub(crate) pane_detach: Cell<Option<(gpui::EntityId, usize)>>,
|
|
/// Where the strip drew each tab's chip and where the sidebar drew each
|
|
/// tab's row, by tab — the geometry a pane dropped on either of them reads
|
|
/// its new place out of.
|
|
///
|
|
/// Written from paint, so what a frame reads is where things were on the
|
|
/// frame before. That is exactly as good as it needs to be: neither the
|
|
/// strip nor the sidebar moves while a drag is in flight. A tab with no
|
|
/// rectangle was not drawn — scrolled out of a full strip, filtered out of
|
|
/// the sidebar — and takes no part in the reading.
|
|
pub(crate) strip_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
|
|
pub(crate) sidebar_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
|
|
/// Where each custom group's block was drawn last frame, so a tab held
|
|
/// over one can be told which group it is over. Only custom groups are
|
|
/// here: a repo group's membership is decided by cwd, so dropping a tab
|
|
/// into one has no meaning to record.
|
|
pub(crate) sidebar_group_slots:
|
|
Rc<RefCell<Vec<(crate::core::group_key::GroupKey, Bounds<Pixels>)>>>,
|
|
/// Where the active tab's panes were last drawn, which is the frame of
|
|
/// reference a drag's landing is worked out in.
|
|
pub(crate) pane_area: Rc<Cell<Option<Bounds<Pixels>>>>,
|
|
pub(crate) sidebar_search: Entity<InputState>,
|
|
pub(crate) file_search: Entity<InputState>,
|
|
_sidebar_search_sub: Subscription,
|
|
_file_search_sub: Subscription,
|
|
settings: Option<SettingsState>,
|
|
pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState,
|
|
/// A close question is on screen. It carries no target: the answer acts on
|
|
/// the tab or pane captured when the question was raised, not on whatever
|
|
/// the app happens to be pointing at by the time it is answered.
|
|
close_prompt_open: bool,
|
|
window_bounds: Bounds<Pixels>,
|
|
pub(crate) workspace: WorkspaceId,
|
|
pub(crate) workspace_rename: Option<WorkspaceRename>,
|
|
pub(crate) group_rename: Option<GroupRename>,
|
|
window_title: std::cell::RefCell<String>,
|
|
pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>,
|
|
pub(crate) switcher: Option<crate::ui::switcher::Switcher>,
|
|
pub(crate) host_snapshots: std::collections::HashMap<
|
|
crate::ui::host_registry::HostId,
|
|
crate::ui::switcher::HostSnapshot,
|
|
>,
|
|
/// Errors reported for a remote host that should be shown inside that host's
|
|
/// switcher group instead of as a global modal or toast.
|
|
pub(crate) remote_host_errors: std::collections::HashMap<String, String>,
|
|
/// Parked switcher groups (#485) whose notice the user dismissed by key —
|
|
/// the entries stay, only the "will not reconnect" block is hidden.
|
|
pub(crate) parked_dismissed: std::collections::HashSet<String>,
|
|
/// A create asked of a machine that was not connected yet; the connect
|
|
/// finishing is what completes it (see `Tty7App::finish_connect`).
|
|
pub(crate) pending_create: Option<crate::ui::switcher::PendingCreate>,
|
|
/// A create whose connect was refused for the control dialect — the state
|
|
/// the "update server" button answers. Set aside rather than dropped, so
|
|
/// the update the user runs next still ends in the workspace they asked
|
|
/// for. Held apart from `pending_create` on purpose: only a connect to the
|
|
/// same machine may spend it, and giving the machine up (dismissing the
|
|
/// refusal, disconnecting) discards it.
|
|
pub(crate) parked_create: Option<crate::ui::switcher::PendingCreate>,
|
|
/// Why the window opened with no terminal in it. Shown on the home screen,
|
|
/// which is otherwise indistinguishable from having closed everything.
|
|
pub(crate) startup_error: Option<gpui::SharedString>,
|
|
}
|
|
|
|
/// What a raised close question is about. Tabs are named by their id, not their
|
|
/// index: a tab that exits on its own while the question is on screen shifts
|
|
/// every index after it, and answering "Close" must not then end a bystander.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum CloseTarget {
|
|
Tab(tty7_core::core::machine::TabId),
|
|
Pane,
|
|
}
|
|
|
|
/// Why closing needs a question first. Closing a tab is the highest-frequency
|
|
/// destructive key in any terminal, and the product's headline claim is that
|
|
/// shells outlive the app — so the one action that permanently ends one has to
|
|
/// name what it is about to end.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub(crate) enum CloseReason {
|
|
LiveSsh,
|
|
Busy(crate::terminal::view::PaneBusy),
|
|
}
|
|
|
|
/// The question to put to the user before ending work that is still going on.
|
|
fn close_prompt(ends_the_tab: bool, reason: &CloseReason) -> (String, String) {
|
|
use crate::terminal::view::PaneBusy;
|
|
use crate::ui::i18n::L10nKey;
|
|
match reason {
|
|
CloseReason::LiveSsh => (
|
|
t(L10nKey::CloseSshConnectionTitle).to_string(),
|
|
t(L10nKey::CloseSshConnectionBody).to_string(),
|
|
),
|
|
CloseReason::Busy(busy) => {
|
|
let title = match ends_the_tab {
|
|
true => t(L10nKey::CloseTabBusyTitle),
|
|
false => t(L10nKey::ClosePaneBusyTitle),
|
|
};
|
|
let body = match busy {
|
|
PaneBusy::Command(what) => t_fmt(L10nKey::CloseBusyCommandBody, &[("what", what)]),
|
|
PaneBusy::Agent(name) => t_fmt(L10nKey::CloseBusyAgentBody, &[("agent", name)]),
|
|
};
|
|
(title.to_string(), body)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub(crate) enum ForkPlacement {
|
|
NewTab,
|
|
Split { axis: Axis, before: bool },
|
|
}
|
|
|
|
pub(crate) struct TabAgentSession {
|
|
pub(crate) fork_label: Option<&'static str>,
|
|
pub(crate) session_id: Option<String>,
|
|
pub(crate) remote: bool,
|
|
}
|
|
|
|
impl TabAgentSession {
|
|
pub(crate) fn forkable(&self) -> bool {
|
|
self.fork_label.is_some() && self.session_id.is_some() && !self.remote
|
|
}
|
|
}
|
|
|
|
/// Maps a backdrop onto the settings dropdown. The dropdown lists the
|
|
/// presets the current Windows build supports, plus the stored value even
|
|
/// when unsupported here (see `theme::backdrop_options`), so the label
|
|
/// always matches what the window actually resolves to.
|
|
#[cfg(target_os = "windows")]
|
|
fn window_backdrop_index(backdrop: WindowBackdrop) -> usize {
|
|
crate::ui::theme::backdrop_options(backdrop)
|
|
.iter()
|
|
.position(|candidate| *candidate == backdrop)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn window_backdrop_from_index(idx: usize, current: WindowBackdrop) -> WindowBackdrop {
|
|
crate::ui::theme::backdrop_options(current)
|
|
.get(idx)
|
|
.copied()
|
|
.unwrap_or(WindowBackdrop::Auto)
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn window_backdrop_label_key(backdrop: WindowBackdrop) -> L10nKey {
|
|
match backdrop {
|
|
WindowBackdrop::Auto => L10nKey::SettingsBackdropAuto,
|
|
WindowBackdrop::Blur => L10nKey::SettingsBackdropBlur,
|
|
WindowBackdrop::Mica => L10nKey::SettingsBackdropMica,
|
|
WindowBackdrop::MicaAlt => L10nKey::SettingsBackdropMicaAlt,
|
|
WindowBackdrop::Acrylic => L10nKey::SettingsBackdropAcrylic,
|
|
WindowBackdrop::Off => L10nKey::SettingsBackdropOff,
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn window_backdrop_labels(backdrop: WindowBackdrop) -> Vec<String> {
|
|
crate::ui::theme::backdrop_options(backdrop)
|
|
.iter()
|
|
.map(|backdrop| t(window_backdrop_label_key(*backdrop)).to_string())
|
|
.collect()
|
|
}
|
|
|
|
/// What a full-window overlay (settings, the opened file, the diff view)
|
|
/// paints between its own fill and its content.
|
|
///
|
|
/// Those overlays fill opaquely on purpose, so the OS backdrop cannot show
|
|
/// through their text — but that fill also sits on top of the background
|
|
/// image the workspace root paints, and would erase it for as long as an
|
|
/// overlay is open. So each one repaints the image, then the workspace's own
|
|
/// translucent fill over it. That second layer is what keeps the overlay
|
|
/// readable: it dims the image to exactly the strength it had when these
|
|
/// overlays were themselves translucent, before they were made opaque.
|
|
///
|
|
/// Empty when the theme has no image — then the opaque fill alone is already
|
|
/// what the overlay wants, and a second pass of the same paint buys nothing.
|
|
pub(crate) fn overlay_surface_layers(cx: &App) -> Vec<gpui::Div> {
|
|
match window_background_image_layer(cx) {
|
|
Some(image) => vec![
|
|
image,
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.bg(crate::ui::theme::workspace_background(cx)),
|
|
],
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The theme's background image as a full-bleed layer.
|
|
pub(crate) fn window_background_image_layer(cx: &App) -> Option<gpui::Div> {
|
|
let image = cx
|
|
.try_global::<crate::ui::presets::ActiveBackground>()?
|
|
.image
|
|
.clone()?;
|
|
Some(
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.overflow_hidden()
|
|
.opacity(image.opacity)
|
|
.child(
|
|
img(image.path)
|
|
.size_full()
|
|
.object_fit(gpui::ObjectFit::Cover),
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Clears the window overrides that are effective on the current platform.
|
|
/// `backdrop_is_local` models whether the Windows-only backdrop participates
|
|
/// in this platform's rendering and therefore belongs to its reset operation.
|
|
fn clear_window_override_values(config: &mut Config, backdrop_is_local: bool) {
|
|
config.window_opacity = None;
|
|
config.window_blur = None;
|
|
if backdrop_is_local {
|
|
config.window_backdrop = WindowBackdrop::Auto;
|
|
}
|
|
}
|
|
|
|
/// The id the fullscreen hint is pushed under, so that entering again replaces
|
|
/// it and leaving takes it away.
|
|
struct FullscreenHint;
|
|
|
|
/// Whether the title bar carries minimize, maximize and close right now.
|
|
///
|
|
/// Not in fullscreen. A fullscreen window has no caption: Windows clears
|
|
/// `WS_CAPTION` and answers `HTCLIENT` along the whole top edge, so the three
|
|
/// buttons would draw, light up under the pointer and do nothing when clicked.
|
|
/// The row they sit at the end of stays, because it is also the tab strip.
|
|
///
|
|
/// Never on macOS, which draws no buttons of its own: those are the system's
|
|
/// traffic lights, and the system hides them itself.
|
|
pub(crate) fn window_controls_drawn(fullscreen: bool) -> bool {
|
|
!cfg!(target_os = "macos") && !fullscreen
|
|
}
|
|
|
|
/// How much of the title bar's trailing end the window buttons take.
|
|
pub(crate) fn window_controls_w(fullscreen: bool) -> f32 {
|
|
match window_controls_drawn(fullscreen) {
|
|
true => WINDOW_CONTROLS_W,
|
|
false => 0.,
|
|
}
|
|
}
|
|
|
|
impl Tty7App {
|
|
pub fn for_workspace(
|
|
id: Option<WorkspaceId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
Self::for_workspace_at(id, None, window, cx)
|
|
}
|
|
|
|
pub fn for_workspace_at(
|
|
id: Option<WorkspaceId>,
|
|
mut initial_cwd: Option<std::path::PathBuf>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let restore = cx.global::<Config>().restore_session;
|
|
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
|
|
let workspace = WorkspaceStore::claim(cx, id);
|
|
let is_remote = WorkspaceStore::all(cx)
|
|
.get(workspace)
|
|
.is_some_and(|w| w.is_remote());
|
|
// Tabs that exist on the machine are shown whatever the restore
|
|
// setting says: that setting decides whether a window comes back at
|
|
// launch, not whether an open one shows what is really in it. The
|
|
// `else` arm below saves this window's session, and saving an empty
|
|
// one over a live tree would erase it.
|
|
let on_machine = id.is_some_and(|id| crate::ui::machine_mirror::machine_holds_tabs(cx, id));
|
|
let hydrate = on_machine || (known && (restore || is_remote));
|
|
let session = hydrate.then(Session::default);
|
|
// A window that is about to pull its layout cannot also open the folder
|
|
// the launch asked for as its first terminal — the pull would find a
|
|
// window that already has a tab, decline to adopt into it, and push
|
|
// that one tab back as the whole workspace. So the folder travels with
|
|
// the hydration and becomes a tab once the layout is up.
|
|
let open_after_hydrate = hydrate.then(|| initial_cwd.take()).flatten();
|
|
let app = Self::with_session_at(Some(workspace), session, initial_cwd, window, cx);
|
|
if hydrate {
|
|
match open_after_hydrate {
|
|
Some(cwd) => {
|
|
crate::ui::tree_sync::hydrate_window_then_open(cx, workspace, cwd);
|
|
}
|
|
None => crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace),
|
|
}
|
|
} else {
|
|
if !is_remote {
|
|
crate::ui::tree_sync::mark_window_informed(cx, workspace);
|
|
}
|
|
app.save_session(cx);
|
|
}
|
|
Self::prompt_daemon_version_mismatch(window, cx);
|
|
crate::ui::remote_connect::register(cx);
|
|
crate::ui::remote_connect::sweep_wsl(cx);
|
|
Self::prompt_remote_daemon_mismatch(window, cx);
|
|
app.reopen_remote_at_startup(cx);
|
|
app
|
|
}
|
|
|
|
/// Put a version-mismatched local server to the user, with no third way out.
|
|
///
|
|
/// Both handshakes compare their version for equality and hang up on
|
|
/// anything else — the pane protocol in `daemon::spawn::ensure_running`, the
|
|
/// control dialect in `host::server`'s hello. So a server whose number
|
|
/// disagrees cannot be talked round, and carrying on beside it is not a
|
|
/// degraded mode but a broken one: panes still spawn while every
|
|
/// machine-tree call is refused, which is how a window comes to open with no
|
|
/// tabs and save none of the ones you make. This used to be offered as "Keep
|
|
/// Shells", and taking it was indistinguishable from the bug.
|
|
///
|
|
/// Restart or quit, then. Quitting is the half that destroys nothing: the
|
|
/// server and every shell under it keep running, which is what makes it a
|
|
/// real answer for someone who would rather go install the matching build
|
|
/// than lose a session mid-flight.
|
|
fn prompt_daemon_version_mismatch(window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(mismatch) = crate::daemon::spawn::take_mismatched_daemon() else {
|
|
return;
|
|
};
|
|
let ours = crate::daemon::protocol::PROTOCOL_VERSION;
|
|
let detail = match &mismatch {
|
|
DaemonMismatch::Protocol(Some(v)) => t_fmt(
|
|
L10nKey::AppRestartServerMismatchDetail,
|
|
&[
|
|
("build", &v.build.to_string()),
|
|
("protocol", &v.protocol.to_string()),
|
|
("ours", &ours.to_string()),
|
|
],
|
|
),
|
|
DaemonMismatch::Protocol(None) => t(L10nKey::AppRestartServerOldDetail).to_string(),
|
|
// The handshake only reports disagreement, not direction, and a
|
|
// daemon left behind by a newer build is as much a mismatch as one
|
|
// left behind by an older. Calling that one "older" would be the
|
|
// same wrong guess the remote path stopped making.
|
|
DaemonMismatch::Dialect(refusal) => t_fmt(
|
|
if refusal.peer < refusal.ours {
|
|
L10nKey::AppRestartServerDialectDetail
|
|
} else {
|
|
L10nKey::AppRestartServerDialectNewerDetail
|
|
},
|
|
&[
|
|
("build", &refusal.peer_build),
|
|
("dialect", &refusal.peer.to_string()),
|
|
("ours", &refusal.ours.to_string()),
|
|
],
|
|
),
|
|
};
|
|
// The one prompt here that does not use `confirm_answers`, because
|
|
// neither answer is "leave it alone" — the app cannot carry on beside a
|
|
// server it cannot speak to. With nothing safe to give Escape, this
|
|
// keeps Quit at index 0 where Return lands: it arrives unasked at
|
|
// launch, the moment a stray Return is most likely, and quitting loses
|
|
// no sessions while restarting the server ends every one of them.
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
t(L10nKey::AppRestartServerTitle),
|
|
Some(&detail),
|
|
&[t(L10nKey::CmdQuitTty7), t(L10nKey::RestartServer)],
|
|
cx,
|
|
);
|
|
cx.spawn(async move |this, cx| match answer.await {
|
|
Ok(1) => {
|
|
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
|
|
}
|
|
Ok(_) => {
|
|
let _ = cx.update(|cx| cx.quit());
|
|
}
|
|
// Dismissed without an answer: the window went away before the
|
|
// question was settled. Arm it again so the next window asks, rather
|
|
// than letting the state this prompt exists to prevent slip through
|
|
// the gap.
|
|
Err(_) => crate::daemon::spawn::note_daemon_mismatch(mismatch),
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn with_session(
|
|
workspace: Option<WorkspaceId>,
|
|
session: Option<Session>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
Self::with_session_at(workspace, session, None, window, cx)
|
|
}
|
|
|
|
fn with_session_at(
|
|
workspace: Option<WorkspaceId>,
|
|
session: Option<Session>,
|
|
initial_cwd: Option<std::path::PathBuf>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let workspace = workspace.unwrap_or_default();
|
|
let pane_ws = crate::ui::remote_workspace::pane_workspace_for(cx, workspace);
|
|
let (
|
|
font_size,
|
|
line_height,
|
|
font_family,
|
|
font_family_bold,
|
|
font_family_italic,
|
|
font_fallbacks,
|
|
font_features,
|
|
terminal_cursor_style,
|
|
terminal_scrollback_limit,
|
|
) = {
|
|
let cfg = cx.global::<Config>();
|
|
(
|
|
cfg.font_size,
|
|
cfg.line_height,
|
|
cfg.font_family.clone(),
|
|
cfg.font_family_bold.clone(),
|
|
cfg.font_family_italic.clone(),
|
|
cfg.font_fallbacks.clone(),
|
|
cfg.font_features
|
|
.as_ref()
|
|
.map(crate::core::config::gpui_font_features),
|
|
cfg.cursor_style,
|
|
cfg.scrollback_limit,
|
|
)
|
|
};
|
|
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
|
|
let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx);
|
|
let editor = crate::ui::code_editor::EditorPanelState::new(window, cx);
|
|
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
|
|
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
|
|
let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1"));
|
|
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
|
|
let mf_description = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderDescription))
|
|
});
|
|
let sidebar_width = cx.global::<Config>().sidebar_width;
|
|
let right_panel_width = cx.global::<Config>().right_panel_width;
|
|
let document_ratio = cx.global::<Config>().document_ratio;
|
|
let right_panel_visible = cx.global::<Config>().right_panel_visible;
|
|
let right_panel_tab = cx.global::<Config>().right_panel_tab;
|
|
let scm_graph_expanded = cx.global::<Config>().scm_graph_expanded;
|
|
let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed;
|
|
let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| {
|
|
this.reload_from_config(window, cx)
|
|
});
|
|
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
|
|
let git_status_watch =
|
|
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| {
|
|
this.maybe_refresh_diff_overlay(cx);
|
|
this.right_panel_refresh_changes(cx);
|
|
cx.notify();
|
|
});
|
|
cx.default_global::<crate::terminal::pane_liveness::PaneLivenessCache>();
|
|
let pane_liveness_watch = cx
|
|
.observe_global::<crate::terminal::pane_liveness::PaneLivenessCache>(|_this, cx| {
|
|
cx.notify();
|
|
});
|
|
let this = cx.weak_entity();
|
|
let keystroke_watch = cx.intercept_keystrokes(move |_ev, _window, cx| {
|
|
let _ = this.update(cx, |this, cx| this.dismiss_mod_hint(cx));
|
|
});
|
|
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
|
|
this.dismiss_mod_hint(cx);
|
|
this.set_link_modifier(false, cx);
|
|
// A modifier released over another window never reports here, so a
|
|
// Ctrl+Tab panel would hang waiting for a commit that cannot come.
|
|
this.switcher_release_hold(cx);
|
|
if window.is_window_active() {
|
|
WorkspaceStore::focus(cx, this.workspace);
|
|
this.refresh_git_status_all(cx);
|
|
}
|
|
});
|
|
let this = cx.weak_entity();
|
|
let appearance_watch = window.observe_window_appearance(move |window, cx| {
|
|
crate::ui::theme::note_system_appearance(window, cx);
|
|
if !cx.global::<Config>().theme_follow_system {
|
|
return;
|
|
}
|
|
apply_theme(Some(window), cx);
|
|
let _ = this.update(cx, |this, cx| {
|
|
this.rebuild_theme_editor(window, cx);
|
|
this.sync_window_opacity_slider(window, cx);
|
|
cx.notify();
|
|
});
|
|
});
|
|
apply_theme(Some(window), cx);
|
|
set_menus(cx);
|
|
let mut startup_error: Option<gpui::SharedString> = None;
|
|
let (tabs, active) = match session {
|
|
None => match new_terminal(
|
|
pane_ws.clone(),
|
|
Some(workspace),
|
|
font_size,
|
|
initial_cwd,
|
|
None,
|
|
None,
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
|
|
Err(e) => {
|
|
log::error!("first terminal failed to start: {e}");
|
|
// The home screen is what a user sees when they have closed
|
|
// everything, and it used to be what they saw when tty7
|
|
// could not open anything — silently, on the very first
|
|
// launch, with the cause only in a log file.
|
|
startup_error = Some(gpui::SharedString::from(t_fmt(
|
|
L10nKey::AppOpenTerminalFailed,
|
|
&[("error", &e.to_string())],
|
|
)));
|
|
(Vec::new(), 0)
|
|
}
|
|
},
|
|
some => {
|
|
let (tabs, active, dropped) =
|
|
tabs_from_session(pane_ws.as_ref(), workspace, some, font_size, window, cx);
|
|
if dropped > 0 {
|
|
startup_error = Some(gpui::SharedString::from(t_plural(
|
|
L10nKey::AppTabsNotRestored,
|
|
dropped,
|
|
&[],
|
|
)));
|
|
}
|
|
(tabs, active)
|
|
}
|
|
};
|
|
let sidebar_search = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchTabs))
|
|
});
|
|
let sidebar_search_sub =
|
|
cx.subscribe_in(&sidebar_search, window, |_this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
});
|
|
let file_search = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchFiles))
|
|
});
|
|
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
});
|
|
let mut app = Self {
|
|
tabs,
|
|
active,
|
|
tab_use_seq: std::cell::Cell::new(0),
|
|
pending_tab: None,
|
|
font_size,
|
|
line_height,
|
|
font_family,
|
|
font_family_bold,
|
|
font_family_italic,
|
|
font_fallbacks,
|
|
font_features,
|
|
terminal_cursor_style,
|
|
terminal_scrollback_limit,
|
|
_config_watch: config_watch,
|
|
_keystroke_watch: keystroke_watch,
|
|
_activation_watch: activation_watch,
|
|
_git_status_watch: git_status_watch,
|
|
_pane_liveness_watch: pane_liveness_watch,
|
|
_appearance_watch: appearance_watch,
|
|
palette: None,
|
|
palette_sub: None,
|
|
theme_preview_restore: None,
|
|
closed: Vec::new(),
|
|
renaming: None,
|
|
worktree_prompt: None,
|
|
maximized: None,
|
|
mod_hint_badges: false,
|
|
mod_hint_gen: 0,
|
|
record_gen: 0,
|
|
home_focus: cx.focus_handle(),
|
|
home_cursor_on: true,
|
|
shells: ShellInventory::default(),
|
|
shells_host: HostId::LOCAL,
|
|
loopback_panel: LoopbackForwardPanelState {
|
|
form_pane_id: None,
|
|
managed: Vec::new(),
|
|
mf_kind: crate::daemon::protocol::SshForwardKind::Local,
|
|
mf_bind_host,
|
|
mf_bind_port,
|
|
mf_target_host,
|
|
mf_target_port,
|
|
mf_description,
|
|
mf_editing: None,
|
|
mf_error: None,
|
|
mf_subs: Vec::new(),
|
|
mf_advanced: false,
|
|
},
|
|
sftp_panel,
|
|
right_panel: Default::default(),
|
|
scm: crate::ui::scm::ScmPanelState {
|
|
graph: crate::ui::scm::GraphState {
|
|
expanded: scm_graph_expanded,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
diff_probes_inflight: Default::default(),
|
|
diff_probes_restale: Default::default(),
|
|
file_tree,
|
|
editor,
|
|
sidebar_width: Rc::new(Cell::new(sidebar_width)),
|
|
sidebar_dragging: Rc::new(Cell::new(false)),
|
|
settings_row_width: Cell::new(f32::MAX),
|
|
settings_viewport_w: Cell::new(f32::MAX),
|
|
settings_hit_anchored: Cell::new(false),
|
|
last_settings_location: (SettingsSection::General, gpui::point(px(0.), px(0.))),
|
|
right_panel_width: Rc::new(Cell::new(right_panel_width)),
|
|
right_panel_dragging: Rc::new(Cell::new(false)),
|
|
document_ratio: Rc::new(Cell::new(document_ratio)),
|
|
document_dragging: Rc::new(Cell::new(false)),
|
|
right_panel_visible,
|
|
right_panel_tab,
|
|
sidebar_collapsed,
|
|
sidebar_scroll: gpui::ScrollHandle::new(),
|
|
reorder: Rc::new(RefCell::new(None)),
|
|
pane_hover: Rc::new(Cell::new(None)),
|
|
pane_drag: Rc::new(RefCell::new(None)),
|
|
tab_merge: Cell::new(None),
|
|
pane_detach: Cell::new(None),
|
|
strip_slots: Rc::new(RefCell::new(Vec::new())),
|
|
sidebar_slots: Rc::new(RefCell::new(Vec::new())),
|
|
sidebar_group_slots: Rc::new(RefCell::new(Vec::new())),
|
|
pane_area: Rc::new(Cell::new(None)),
|
|
sidebar_search,
|
|
_sidebar_search_sub: sidebar_search_sub,
|
|
file_search,
|
|
_file_search_sub: file_search_sub,
|
|
settings: None,
|
|
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
|
|
close_prompt_open: false,
|
|
window_bounds: window_bounds_to_remember(window),
|
|
workspace,
|
|
workspace_rename: None,
|
|
group_rename: None,
|
|
window_title: std::cell::RefCell::new(String::new()),
|
|
connect: None,
|
|
switcher: None,
|
|
host_snapshots: std::collections::HashMap::new(),
|
|
remote_host_errors: std::collections::HashMap::new(),
|
|
parked_dismissed: std::collections::HashSet::new(),
|
|
pending_create: None,
|
|
parked_create: None,
|
|
startup_error,
|
|
};
|
|
if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 {
|
|
crate::ui::tray::init(cx);
|
|
}
|
|
app.refresh_shells(cx);
|
|
cx.on_app_quit(|app, cx| {
|
|
app.save_session(cx);
|
|
crate::core::window_state::WindowState::from_bounds(app.window_bounds).save();
|
|
async move {}
|
|
})
|
|
.detach();
|
|
|
|
cx.observe_window_bounds(window, |this, window, _cx| {
|
|
this.window_bounds = window_bounds_to_remember(window);
|
|
})
|
|
.detach();
|
|
|
|
// The home page's cursor, on the terminal's own schedule. It ticks
|
|
// whether or not the page is up — a timer that wakes twice a second to
|
|
// compare a `Vec`'s length against zero costs nothing — but only asks
|
|
// for a frame when the page is the thing on screen.
|
|
cx.spawn(async move |this, cx| {
|
|
loop {
|
|
cx.background_executor()
|
|
.timer(std::time::Duration::from_millis(HOME_CURSOR_BLINK_MS))
|
|
.await;
|
|
if this
|
|
.update(cx, |this, cx| {
|
|
if this.tabs.is_empty() {
|
|
this.home_cursor_on = !this.home_cursor_on;
|
|
cx.notify();
|
|
}
|
|
})
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let weak_app = cx.weak_entity();
|
|
window.on_window_should_close(cx, move |_window, cx| {
|
|
if let Some(app) = weak_app.upgrade() {
|
|
app.update(cx, |app, cx| app.prepare_window_close(cx));
|
|
}
|
|
true
|
|
});
|
|
|
|
app.focus_active(window, cx);
|
|
app
|
|
}
|
|
|
|
pub(crate) fn save_session(&self, cx: &mut App) {
|
|
for view in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) {
|
|
let Some(owner) = view.read(cx).owner_workspace() else {
|
|
continue;
|
|
};
|
|
if owner != self.workspace {
|
|
log::error!(
|
|
"save_session: window of workspace {} is recording pane {} \
|
|
that was created for workspace {owner} — cross-workspace \
|
|
write detected, please report this",
|
|
self.workspace,
|
|
view.read(cx).pane_id,
|
|
);
|
|
}
|
|
}
|
|
WorkspaceStore::record_geometry(
|
|
cx,
|
|
self.workspace,
|
|
WindowState::from_bounds(self.window_bounds),
|
|
);
|
|
crate::ui::tree_sync::sync_window(self, cx);
|
|
}
|
|
|
|
pub(crate) fn detach_workspace(&self, cx: &mut App) {
|
|
self.save_session(cx);
|
|
let answered = WorkspaceStore::machine_is_connected(cx, self.workspace);
|
|
if self.tabs.is_empty()
|
|
&& answered
|
|
&& crate::ui::tree_sync::workspace_is_disposable(cx, self.workspace)
|
|
{
|
|
crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| {
|
|
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
|
});
|
|
WorkspaceStore::remove(cx, self.workspace);
|
|
} else {
|
|
WorkspaceStore::close_window(cx, self.workspace);
|
|
}
|
|
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
|
|
crate::ui::tree_sync::forget(cx, self.workspace);
|
|
crate::ui::windows::refresh_menu(cx);
|
|
}
|
|
|
|
/// Opens a second window, on a workspace of its own.
|
|
///
|
|
/// A window on *this* workspace is not the other reading of "new window";
|
|
/// it is a thing the app cannot hold. `WindowRegistry` is keyed by
|
|
/// workspace — `window_for`, `app_for`, `unregister` and `rebind` all
|
|
/// address a window by the workspace it shows — and `windows::open`
|
|
/// answers a workspace that already has a window by activating it. Asking
|
|
/// for the current one here would raise the window you are already in.
|
|
///
|
|
/// So this is the same call the switcher makes for "Open in New Window",
|
|
/// with no workspace named: a fresh one, which is also what a new window
|
|
/// holds everywhere else it is offered.
|
|
pub(crate) fn new_window(&self, cx: &mut App) {
|
|
crate::ui::windows::open(cx, None);
|
|
}
|
|
|
|
fn prepare_window_close(&self, cx: &mut App) {
|
|
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
|
|
self.detach_workspace(cx);
|
|
if last_window {
|
|
// With a tray icon, closing the last window retires to the
|
|
// tray: the daemon stays reachable (show / quit-and-stop)
|
|
// instead of being orphaned behind a dead icon. Without one
|
|
// the app quits — the only way it stays visible at all.
|
|
//
|
|
// The icon has to actually be up, not merely asked for: the
|
|
// backend can fail for the whole run (a Linux session with no
|
|
// StatusNotifier host), and retiring into an icon that never
|
|
// appeared leaves a process with no window and no tray — no
|
|
// way back in, and the daemon still held.
|
|
let retire_to_tray =
|
|
cx.global::<Config>().show_tray_icon && crate::ui::tray::icon_is_up();
|
|
if !retire_to_tray {
|
|
cx.spawn(async move |cx| {
|
|
let _ = cx.update(|cx| cx.quit());
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn close_window(&self, window: &mut Window, cx: &mut App) {
|
|
self.prepare_window_close(cx);
|
|
window.remove_window();
|
|
}
|
|
|
|
pub(crate) fn teardown_workspace_forwards(&self, cx: &gpui::App) {
|
|
let Some(route) = self
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|tab| tab.pane.terminals())
|
|
.find_map(|leaf| {
|
|
let view = leaf.read(cx);
|
|
let workspace = view.workspace().cloned()?;
|
|
Some(ForwardRoute {
|
|
pane_id: view.pane_id,
|
|
workspace: Some(workspace),
|
|
})
|
|
})
|
|
else {
|
|
return;
|
|
};
|
|
cx.background_executor()
|
|
.spawn(async move {
|
|
let left = route.teardown();
|
|
if !left.is_empty() {
|
|
log::warn!("{} forwards survived a workspace teardown", left.len());
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn stop_workspace(
|
|
&mut self,
|
|
id: WorkspaceId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
crate::ui::windows::confirm_and_stop(cx, window, id);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn delete_workspace(
|
|
&mut self,
|
|
id: WorkspaceId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
crate::ui::windows::confirm_and_delete(cx, window, id);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn select_workspace_slot(
|
|
&mut self,
|
|
index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some((id, _open)) = crate::ui::windows::menu_order(cx).get(index).copied() else {
|
|
return;
|
|
};
|
|
self.reveal_workspace(id, window, cx);
|
|
}
|
|
|
|
pub(crate) fn reveal_workspace(
|
|
&mut self,
|
|
id: WorkspaceId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, id) {
|
|
let _ = handle.update(cx, |_, other, _| other.activate_window());
|
|
return;
|
|
}
|
|
// Switching workspaces happens in place. A second window is something
|
|
// you ask for — with the platform modifier, or "Open in New Window".
|
|
self.switch_workspace(Some(id), window, cx);
|
|
}
|
|
|
|
/// Trades this window's workspace for another one. `None` starts a fresh
|
|
/// workspace here rather than opening one in a new window.
|
|
pub(crate) fn switch_workspace(
|
|
&mut self,
|
|
id: Option<WorkspaceId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let previous = self.workspace;
|
|
if id == Some(previous) {
|
|
return;
|
|
}
|
|
// Anything parked for the workspace we are leaving is now meaningless.
|
|
self.pending_tab = None;
|
|
if self.tabs.is_empty() && crate::ui::tree_sync::workspace_is_disposable(cx, previous) {
|
|
crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| {
|
|
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
|
});
|
|
WorkspaceStore::remove(cx, previous);
|
|
} else if self.tabs.is_empty() {
|
|
WorkspaceStore::close_window(cx, previous);
|
|
} else {
|
|
self.save_session(cx);
|
|
WorkspaceStore::close_window(cx, previous);
|
|
}
|
|
crate::ui::tree_sync::forget(cx, previous);
|
|
|
|
let claimed = WorkspaceStore::claim(cx, id);
|
|
crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed);
|
|
crate::ui::remote_workspace::RemoteLinks::supervise(cx, claimed);
|
|
// Forgotten on the way in as well as on the way out. `adopt_workspace`
|
|
// puts the empty session up before the pull below orders the real one,
|
|
// and it saves what it put up: a window showing nothing, syncing
|
|
// against whatever this workspace was left Primed and informed with
|
|
// the last time it was visited, which is a Full diff that closes every
|
|
// tab on the machine (#716). Arriving speaks for nothing until a pull
|
|
// says otherwise.
|
|
crate::ui::tree_sync::forget(cx, claimed);
|
|
self.adopt_workspace(claimed, Session::default(), window, cx);
|
|
// This method runs under the app's own update lease, so the tabs the
|
|
// pull must see are the ones just adopted here — reading the app back
|
|
// out of `cx` (as `tabs_on_screen` would) is an abort in gpui.
|
|
let showing = self.tabs.iter().map(|t| t.tree_id.get()).collect();
|
|
crate::ui::tree_sync::hydrate_window_with_tabs(cx, claimed, showing);
|
|
}
|
|
|
|
pub(crate) fn adopt_workspace(
|
|
&mut self,
|
|
id: WorkspaceId,
|
|
session: Session,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let previous_host = self.spawn_host(cx);
|
|
self.workspace = id;
|
|
self.rebind_host(previous_host, cx);
|
|
self.refresh_shells(cx);
|
|
let font_size = self.font_size;
|
|
let pane_ws = self.window_workspace(cx);
|
|
let (tabs, active, dropped) = tabs_from_session(
|
|
pane_ws.as_ref(),
|
|
self.workspace,
|
|
Some(session),
|
|
font_size,
|
|
window,
|
|
cx,
|
|
);
|
|
if dropped > 0 {
|
|
window.push_notification(t_plural(L10nKey::AppTabsNotRestored, dropped, &[]), cx);
|
|
}
|
|
self.tabs = tabs;
|
|
self.active = active;
|
|
self.maximized = None;
|
|
self.save_session(cx);
|
|
crate::ui::windows::refresh_menu(cx);
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn reopen_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(st) = self.closed.pop() else {
|
|
return;
|
|
};
|
|
let pane_ws = self.window_workspace(cx);
|
|
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(pane_ws.as_ref()));
|
|
let Some(pane) = session_to_pane(
|
|
pane_ws.as_ref(),
|
|
self.workspace,
|
|
&st.pane,
|
|
alive.as_ref(),
|
|
self.font_size,
|
|
window,
|
|
cx,
|
|
) else {
|
|
window.push_notification(t(L10nKey::AppReopenTabFailed), cx);
|
|
self.closed.push(st);
|
|
return;
|
|
};
|
|
self.remember_active_pane(window, cx);
|
|
self.maximized = None;
|
|
let insert_at = self.new_tab_insert_at(cx);
|
|
self.tabs.insert(
|
|
insert_at,
|
|
Tab {
|
|
pane,
|
|
name: st.name,
|
|
last_focused: None,
|
|
zoomed: None,
|
|
diff_overlay: None,
|
|
code: None,
|
|
overlay_top: OverlayTop::default(),
|
|
document_layout: None,
|
|
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
|
|
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
|
last_used: std::cell::Cell::new(0),
|
|
focus_origin: Default::default(),
|
|
},
|
|
);
|
|
self.active = insert_at;
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn owns_leaf(&self, leaf_id: u64) -> bool {
|
|
self.tabs.iter().any(|t| {
|
|
t.pane
|
|
.leaves()
|
|
.iter()
|
|
.any(|l| l.entity_id().as_u64() == leaf_id)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn agent_rows(&self, cx: &App) -> Vec<crate::ui::tray::AgentRow> {
|
|
use crate::core::cli_agent::AgentStatus;
|
|
let mut agents = Vec::new();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let view = leaf.read(cx);
|
|
let Some(agent) = view.agent() else { continue };
|
|
let status = view
|
|
.agent_session()
|
|
.map(|s| s.status)
|
|
.unwrap_or(AgentStatus::Idle);
|
|
let dir = view
|
|
.cwd()
|
|
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));
|
|
let branch = view.git_status(cx).map(|g| g.branch);
|
|
let detail = match (dir, branch) {
|
|
(Some(dir), Some(branch)) => format!("{dir} @ {branch}"),
|
|
(Some(dir), None) => dir,
|
|
(None, _) => String::new(),
|
|
};
|
|
agents.push(crate::ui::tray::AgentRow {
|
|
leaf_id: leaf.entity_id().as_u64(),
|
|
agent,
|
|
status,
|
|
detail,
|
|
});
|
|
}
|
|
}
|
|
agents
|
|
}
|
|
|
|
pub(crate) fn handle_tray_action(
|
|
&mut self,
|
|
action: crate::ui::tray::TrayAction,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
use crate::ui::tray::TrayAction;
|
|
fn surface_window(window: &mut Window, cx: &mut App) {
|
|
cx.activate(true);
|
|
window.activate_window();
|
|
}
|
|
match action {
|
|
TrayAction::ShowWindow => surface_window(window, cx),
|
|
TrayAction::RevealPane { leaf_id } => {
|
|
let tab_ix = self.tabs.iter().position(|t| {
|
|
t.pane
|
|
.leaves()
|
|
.iter()
|
|
.any(|l| l.entity_id().as_u64() == leaf_id)
|
|
});
|
|
if let Some(ix) = tab_ix {
|
|
self.activate(ix, window, cx);
|
|
if self
|
|
.maximized
|
|
.as_ref()
|
|
.is_some_and(|m| m.entity_id().as_u64() != leaf_id)
|
|
{
|
|
self.maximized = None;
|
|
}
|
|
if let Some(leaf) = self.tabs[ix]
|
|
.pane
|
|
.leaves()
|
|
.into_iter()
|
|
.find(|l| l.entity_id().as_u64() == leaf_id)
|
|
{
|
|
self.tabs[ix].last_focused = Some(leaf.entity_id());
|
|
self.focus_leaf(&leaf, window, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
surface_window(window, cx);
|
|
}
|
|
TrayAction::SetNotifyMode(mode) => self.set_notify_mode(mode, cx),
|
|
TrayAction::OpenSettings => {
|
|
surface_window(window, cx);
|
|
if self.settings.is_none() {
|
|
self.toggle_settings(window, cx);
|
|
}
|
|
}
|
|
TrayAction::CheckForUpdates => {
|
|
surface_window(window, cx);
|
|
self.check_for_updates_now(window, cx);
|
|
}
|
|
// One exit path with one meaning: quit the app and stop the server
|
|
// (after the confirmation that protects running shells). Closing
|
|
// the window is the keep-everything exit — it retires to the tray.
|
|
TrayAction::Quit => self.quit_stop_sessions(window, cx),
|
|
}
|
|
}
|
|
|
|
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.activate(true);
|
|
window.activate_window();
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
t(crate::ui::i18n::L10nKey::QuitStopServerTitle),
|
|
Some(t(crate::ui::i18n::L10nKey::QuitStopServerBody)),
|
|
&crate::ui::confirm_answers(
|
|
t(crate::ui::i18n::L10nKey::QuitAndStop),
|
|
t(crate::ui::i18n::L10nKey::Cancel),
|
|
),
|
|
cx,
|
|
);
|
|
cx.spawn(async move |_this, cx| {
|
|
if !matches!(answer.await, Ok(0)) {
|
|
return;
|
|
}
|
|
cx.background_spawn(async { crate::daemon::spawn::stop() })
|
|
.await;
|
|
let _ = cx.update(|cx| cx.quit());
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn restart_window_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else {
|
|
self.restart_daemon(window, cx);
|
|
return;
|
|
};
|
|
let target = remote.target.clone();
|
|
let label = crate::ui::remote_connect::route_label(cx, &remote);
|
|
if !target.hosts_our_server() {
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppRestartServerNoServer, &[("label", &label)]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
// A server the other side of a dialect bump has nothing to restart
|
|
// *into*: the binary this build launches was never installed over
|
|
// there, and the installer now refuses rather than killing the one
|
|
// that is running. Updating is the move that works, so offer that,
|
|
// under its own name and with its own warning about ending sessions.
|
|
if let Some(crate::ui::remote_workspace::RemoteStatus::ServerMismatch(refusal)) =
|
|
self.remote_status(cx)
|
|
{
|
|
let action = crate::ui::remote_workspace::mismatch_action_key(&refusal);
|
|
self.confirm_replace_remote_server(target, label, action, window, cx);
|
|
return;
|
|
}
|
|
self.confirm_restart_remote_server(target, label, window, cx);
|
|
}
|
|
|
|
/// The palette's "Update tty7 server" for this computer. The app bundle
|
|
/// already carries the new server, so updating it is restarting onto this
|
|
/// build — the same restart, with the same confirmation, as everywhere
|
|
/// else. Only when the running server is already this build is there
|
|
/// nothing to do, and then the user is told so rather than asked to end
|
|
/// their shells for no change.
|
|
fn update_local_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if crate::daemon::spawn::local_daemon_is_this_build() {
|
|
window.push_notification(
|
|
t_fmt(
|
|
L10nKey::AppLocalServerAlreadyCurrent,
|
|
&[("build", env!("CARGO_PKG_VERSION"))],
|
|
),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
self.restart_daemon(window, cx);
|
|
}
|
|
|
|
/// The same for the machine this window's workspace lives on. The palette
|
|
/// only offers it there, but the row can outlive a workspace switch, so the
|
|
/// checks are made again rather than trusted.
|
|
fn update_window_remote_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else {
|
|
self.update_local_server(window, cx);
|
|
return;
|
|
};
|
|
let target = remote.target.clone();
|
|
let label = crate::ui::remote_connect::route_label(cx, &remote);
|
|
if !target.hosts_our_server() {
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppRestartServerNoServer, &[("label", &label)]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
self.confirm_update_remote_server(target, label, window, cx);
|
|
}
|
|
|
|
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Two different actions wearing one name. Where the service can rewrite
|
|
// itself in place, nothing in a pane is interrupted and promising the
|
|
// user a bloodbath would be a lie that costs them the feature; where it
|
|
// cannot, every running command really does end, and that is the one
|
|
// thing they need to be told before they agree.
|
|
let in_place =
|
|
crate::daemon::spawn::local_daemon_supports(crate::daemon::protocol::FEATURE_HANDOFF);
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
t(L10nKey::AppRestartServerTitle),
|
|
Some(t(if in_place {
|
|
L10nKey::AppRestartServerBodyInPlace
|
|
} else {
|
|
L10nKey::AppRestartServerBody
|
|
})),
|
|
&crate::ui::confirm_answers(
|
|
t(L10nKey::AppRestart),
|
|
t(crate::ui::i18n::L10nKey::Cancel),
|
|
),
|
|
cx,
|
|
);
|
|
cx.spawn(async move |this, cx| {
|
|
if !matches!(answer.await, Ok(0)) {
|
|
return;
|
|
}
|
|
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn restart_daemon_confirmed(&mut self, cx: &mut Context<Self>) {
|
|
cx.spawn(async move |this, cx| {
|
|
if this
|
|
.update_in(cx, |this, _window, cx| {
|
|
this.save_session(cx);
|
|
this.maximized = None;
|
|
this.tabs.clear();
|
|
this.active = 0;
|
|
cx.notify();
|
|
})
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let restarted = cx
|
|
.background_spawn(async move {
|
|
// The same test that chose the dialog's copy chooses the
|
|
// action, because the copy is a promise. A daemon that
|
|
// advertises the handoff was described as replacing itself
|
|
// with nothing interrupted — if that fails, the failure is
|
|
// shown, not silently traded for the restart that kills
|
|
// every pane the user was just told would live. The
|
|
// stop-and-start path is only taken where its bloodbath is
|
|
// what the dialog actually said.
|
|
if crate::daemon::spawn::local_daemon_supports(
|
|
crate::daemon::protocol::FEATURE_HANDOFF,
|
|
) {
|
|
crate::daemon::spawn::hand_off()
|
|
} else {
|
|
crate::daemon::spawn::restart()
|
|
}
|
|
})
|
|
.await;
|
|
Self::settle_after_restart(this, restarted, cx).await;
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// Put the window back together once the restart has been attempted, either
|
|
/// way it went.
|
|
///
|
|
/// Split into three steps because the middle one must not run with this
|
|
/// window's entity leased. `resync_after_local_daemon_change` takes the
|
|
/// whole `App` and rebuilds *every* local window from the tree, and the
|
|
/// first thing it asks each one is which tabs it is showing — which it gets
|
|
/// by reading that window's `Tty7App` back out of the registry. Called from
|
|
/// inside `update_in`, the window it reaches for first is the one already
|
|
/// leased to the closure, and gpui's answer to a double lease is a panic
|
|
/// that takes the process with it: clicking Restart Server made the whole
|
|
/// app vanish.
|
|
async fn settle_after_restart(
|
|
this: gpui::WeakEntity<Self>,
|
|
restarted: anyhow::Result<()>,
|
|
cx: &mut gpui::AsyncApp,
|
|
) {
|
|
// A refused handoff leaves the daemon exactly as it was, still serving
|
|
// the panes this window just dropped. Leaving it at the error here
|
|
// strands them: every restore path is tree-driven, and the next sync of
|
|
// this emptied window would diff into "close every tab" against the
|
|
// mirror — deleting the pane records under the still-running shells, or
|
|
// the whole workspace if the user simply closes the window first (#554).
|
|
// So the resync below runs either way; this step only says so on screen.
|
|
if this
|
|
.update_in(cx, |this, window, cx| {
|
|
if let Err(e) = &restarted {
|
|
log::error!("restart background service failed, resyncing from the tree: {e}");
|
|
let text = t_fmt(
|
|
L10nKey::AppRestartServerFailed,
|
|
&[("error", &e.to_string())],
|
|
);
|
|
this.startup_error = Some(gpui::SharedString::from(text.clone()));
|
|
window.push_notification(text, cx);
|
|
}
|
|
})
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The link we held pointed at the server we just killed; the reconnect
|
|
// finds a new process whose registry knows nothing about these panes.
|
|
// The helper drops the dead link first — a pull sent down it dies on a
|
|
// dead socket before the reader notices — and rebuilds every local
|
|
// window from the tree. Where the restart failed and the daemon is
|
|
// really gone, the pull misses and the rehydration debt keeps the empty
|
|
// window from being pushed back up.
|
|
//
|
|
// The invalidating helper, not the one the reconnect uses: nothing here
|
|
// handshaked a link. Half of `hand_off`'s failures happen *after* the
|
|
// exec — a daemon that never started listening again is gone, and the
|
|
// client we still hold points at its socket, which `is_connected` keeps
|
|
// calling good until its reader sees the EOF.
|
|
let _ = cx.update(crate::ui::tree_sync::resync_after_local_daemon_change);
|
|
|
|
let _ = this.update_in(cx, |this, window, cx| {
|
|
this.focus_active(window, cx);
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn set_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
|
|
let size = size.clamp(FONT_SIZE_MIN, FONT_SIZE_MAX);
|
|
self.font_size = size;
|
|
let px_size = px(size);
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.font_size = px_size;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.font_size = size;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn change_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.set_font_size(self.font_size + delta, cx);
|
|
}
|
|
|
|
pub(crate) fn reset_font_size(&mut self, cx: &mut Context<Self>) {
|
|
self.set_font_size(Config::default().font_size, cx);
|
|
}
|
|
|
|
fn set_ui_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
|
|
use crate::core::config::{UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN};
|
|
let size = size.clamp(UI_FONT_SIZE_MIN, UI_FONT_SIZE_MAX);
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.ui_font_size == size {
|
|
return;
|
|
}
|
|
cfg.ui_font_size = size;
|
|
self.persist_settings_config(cx);
|
|
// Unlike the settings that only redraw the window they were changed
|
|
// in, this one re-lays-out every open window, and each reads the new
|
|
// rem from the global on its own next frame.
|
|
cx.refresh_windows();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn ui_font_size(&self, cx: &gpui::App) -> f32 {
|
|
cx.global::<Config>().ui_font_size
|
|
}
|
|
|
|
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.set_ui_font_size(self.ui_font_size(cx) + delta, cx);
|
|
}
|
|
|
|
pub(crate) fn reset_ui_font_size(&mut self, cx: &mut Context<Self>) {
|
|
self.set_ui_font_size(Config::default().ui_font_size, cx);
|
|
}
|
|
|
|
fn set_line_height(&mut self, mul: f32, cx: &mut Context<Self>) {
|
|
let mul = mul.clamp(LINE_HEIGHT_MIN, LINE_HEIGHT_MAX);
|
|
self.line_height = mul;
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.line_height_mul = mul;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.line_height = mul;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn change_line_height(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.set_line_height(self.line_height + delta, cx);
|
|
}
|
|
|
|
pub(crate) fn reset_line_height(&mut self, cx: &mut Context<Self>) {
|
|
self.set_line_height(Config::default().line_height, cx);
|
|
}
|
|
|
|
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.theme_draft_dirty() {
|
|
let id = id.to_string();
|
|
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
|
|
this.set_preset(&id, window, cx)
|
|
});
|
|
return;
|
|
}
|
|
// A confirmed pick ends any preview: there is nothing left to roll back.
|
|
self.theme_preview_restore = None;
|
|
self.write_preset(id, cx);
|
|
self.after_theme_change(window, cx);
|
|
}
|
|
|
|
/// Points whichever preset slot is live at `id`, in memory only.
|
|
fn write_preset(&mut self, id: &str, cx: &mut Context<Self>) {
|
|
let dark_now = crate::ui::theme::system_dark(cx);
|
|
let cfg = cx.global_mut::<Config>();
|
|
if !cfg.theme_follow_system {
|
|
cfg.theme_preset = id.to_string();
|
|
} else if dark_now {
|
|
cfg.theme_preset_dark = id.to_string();
|
|
} else {
|
|
cfg.theme_preset_light = id.to_string();
|
|
}
|
|
}
|
|
|
|
/// Shows a preset for as long as the palette's theme picker is open, so
|
|
/// arrowing through the list is how you find out what a theme looks like.
|
|
/// Nothing is written to `config.json` until the pick is confirmed.
|
|
pub(crate) fn preview_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.theme_preview_restore.is_none() {
|
|
self.theme_preview_restore = Some(crate::ui::theme::effective_preset_id(cx));
|
|
}
|
|
self.write_preset(id, cx);
|
|
self.apply_theme_change(false, window, cx);
|
|
}
|
|
|
|
/// Puts back the preset that was live before the preview started. A no-op
|
|
/// when nothing is being previewed.
|
|
pub(crate) fn cancel_preset_preview(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(id) = self.theme_preview_restore.take() else {
|
|
return;
|
|
};
|
|
self.write_preset(&id, cx);
|
|
self.apply_theme_change(false, window, cx);
|
|
}
|
|
|
|
pub(crate) fn set_slot_preset(
|
|
&mut self,
|
|
dark_slot: bool,
|
|
id: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.theme_draft_dirty() {
|
|
let id = id.to_string();
|
|
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
|
|
this.set_slot_preset(dark_slot, &id, window, cx)
|
|
});
|
|
return;
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
if dark_slot {
|
|
cfg.theme_preset_dark = id.to_string();
|
|
} else {
|
|
cfg.theme_preset_light = id.to_string();
|
|
}
|
|
self.after_theme_change(window, cx);
|
|
}
|
|
|
|
pub(crate) fn set_theme_follow_system(
|
|
&mut self,
|
|
on: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.theme_draft_dirty() {
|
|
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
|
|
this.set_theme_follow_system(on, window, cx)
|
|
});
|
|
return;
|
|
}
|
|
if on {
|
|
let manual = cx.global::<Config>().theme_preset.clone();
|
|
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.theme_follow_system = true;
|
|
if manual_dark {
|
|
cfg.theme_preset_dark = manual;
|
|
} else {
|
|
cfg.theme_preset_light = manual;
|
|
}
|
|
} else {
|
|
let effective = crate::ui::theme::effective_preset_id(cx);
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.theme_follow_system = false;
|
|
cfg.theme_preset = effective;
|
|
}
|
|
self.after_theme_change(window, cx);
|
|
let slot = if on {
|
|
if crate::ui::theme::system_dark(cx) {
|
|
crate::ui::settings::ThemeSlot::Dark
|
|
} else {
|
|
crate::ui::settings::ThemeSlot::Light
|
|
}
|
|
} else {
|
|
crate::ui::settings::ThemeSlot::Manual
|
|
};
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.theme_panel_slot = slot;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_theme_legible_palette(
|
|
&mut self,
|
|
on: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
cx.global_mut::<Config>().theme_legible_palette = on;
|
|
self.after_theme_change(window, cx);
|
|
}
|
|
|
|
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.apply_theme_change(true, window, cx);
|
|
}
|
|
|
|
/// Repaints everything a theme change touches. `persist` is false for a
|
|
/// palette preview, which repaints on every arrow key and must not turn
|
|
/// each of those keystrokes into a `config.json` write.
|
|
fn apply_theme_change(&mut self, persist: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
apply_theme(Some(window), cx);
|
|
set_menus(cx);
|
|
if persist {
|
|
self.persist_settings_config(cx);
|
|
}
|
|
self.rebuild_theme_editor(window, cx);
|
|
self.sync_window_opacity_slider(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Opens or closes the theme picker, and moves the caret with it.
|
|
///
|
|
/// The panel leads with a search box, and it opened unfocused — so the
|
|
/// first thing typed at a panel whose whole job is picking one of nine
|
|
/// themes went nowhere. Closing hands the caret back to the settings
|
|
/// search rather than leaving it on a box that is no longer drawn.
|
|
pub(crate) fn toggle_theme_panel(
|
|
&mut self,
|
|
slot: crate::ui::settings::ThemeSlot,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let opened = match self.active_settings_mut() {
|
|
Some(s) if s.theme_panel_open && s.theme_panel_slot == slot => {
|
|
s.theme_panel_open = false;
|
|
false
|
|
}
|
|
Some(s) => {
|
|
s.theme_panel_open = true;
|
|
s.theme_panel_slot = slot;
|
|
true
|
|
}
|
|
None => return,
|
|
};
|
|
self.focus_theme_panel(opened, window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn close_theme_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.active_settings_mut().is_none() {
|
|
return;
|
|
}
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.theme_panel_open = false;
|
|
}
|
|
self.focus_theme_panel(false, window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn focus_theme_panel(&mut self, opened: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
let handle = self.settings.as_ref().map(|s| match opened {
|
|
true => s.theme_search.read(cx).focus_handle(cx),
|
|
false => s.search.read(cx).focus_handle(cx),
|
|
});
|
|
if let Some(handle) = handle {
|
|
window.focus(&handle, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn open_themes_folder(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(dir) = crate::ui::presets::themes_dir() else {
|
|
log::warn!("no config directory, so no themes folder to open");
|
|
return;
|
|
};
|
|
// The folder is created on demand, so the first click on a fresh
|
|
// install is also the one that can fail. Handing an absent path to the
|
|
// file manager just opens nothing.
|
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
|
log::warn!("could not create {}: {e}", dir.display());
|
|
crate::ui::host_ops::HostOps::notify_err(
|
|
window,
|
|
cx,
|
|
&t_fmt(
|
|
L10nKey::OpenInFileManagerFailed,
|
|
&[("path", &dir.display().to_string())],
|
|
),
|
|
&e,
|
|
);
|
|
return;
|
|
}
|
|
cx.open_with_system(&dir);
|
|
}
|
|
|
|
pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let id = crate::ui::theme::effective_preset_id(cx);
|
|
let theme = crate::ui::presets::by_id(cx, &id);
|
|
match crate::ui::presets::fork_to_file(&theme) {
|
|
Ok(new_id) => {
|
|
crate::ui::presets::load_registry(cx);
|
|
self.set_preset(&new_id, window, cx);
|
|
}
|
|
// A button that does nothing is the worst kind of failure: there
|
|
// is no way to tell it from "I clicked the wrong thing".
|
|
Err(e) => {
|
|
log::warn!("failed to duplicate theme: {e}");
|
|
crate::ui::host_ops::HostOps::notify_err(
|
|
window,
|
|
cx,
|
|
t(L10nKey::ThemeDuplicateFailed),
|
|
&e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn theme_draft_dirty(&self) -> bool {
|
|
self.active_settings()
|
|
.is_some_and(|s| s.theme_draft.is_some())
|
|
}
|
|
|
|
pub(crate) fn save_theme_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
|
let Some((_, draft)) = self.active_settings().and_then(|s| s.theme_draft.clone()) else {
|
|
return true;
|
|
};
|
|
if let Err(error) = crate::ui::presets::write_theme_file(&draft) {
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.theme_draft_error = Some(error.to_string());
|
|
}
|
|
crate::ui::host_ops::HostOps::notify_err(
|
|
window,
|
|
cx,
|
|
t(L10nKey::ThemeSaveFailed),
|
|
&error,
|
|
);
|
|
return false;
|
|
}
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.theme_draft = None;
|
|
s.theme_draft_error = None;
|
|
}
|
|
cx.notify();
|
|
true
|
|
}
|
|
|
|
pub(crate) fn cancel_theme_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.theme_draft_error = None;
|
|
}
|
|
if let Some((original, _)) = self
|
|
.active_settings_mut()
|
|
.and_then(|s| s.theme_draft.take())
|
|
{
|
|
let mut themes = crate::ui::presets::all(cx);
|
|
if let Some(slot) = themes.iter_mut().find(|t| t.id == original.id) {
|
|
*slot = original;
|
|
}
|
|
cx.set_global(crate::ui::presets::Themes(themes));
|
|
apply_theme(Some(window), cx);
|
|
self.rebuild_theme_editor(window, cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn mutate_active_theme(
|
|
&mut self,
|
|
mutate: impl FnOnce(&mut crate::ui::presets::Theme),
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let id = crate::ui::theme::effective_preset_id(cx);
|
|
let mut theme = crate::ui::presets::by_id(cx, &id);
|
|
if !theme.editable() {
|
|
return;
|
|
}
|
|
mutate(&mut theme);
|
|
if let Some(state) = self.active_settings_mut() {
|
|
let original = state
|
|
.theme_draft
|
|
.as_ref()
|
|
.filter(|(original, _)| original.id == id)
|
|
.map(|(original, _)| original.clone())
|
|
.unwrap_or_else(|| crate::ui::presets::by_id(cx, &id));
|
|
if crate::ui::presets::to_yaml(&original) == crate::ui::presets::to_yaml(&theme) {
|
|
state.theme_draft = None;
|
|
} else {
|
|
state.theme_draft = Some((original, theme.clone()));
|
|
}
|
|
let mut themes = crate::ui::presets::all(cx);
|
|
if let Some(slot) = themes.iter_mut().find(|t| t.id == id) {
|
|
*slot = theme;
|
|
}
|
|
cx.set_global(crate::ui::presets::Themes(themes));
|
|
} else {
|
|
if let Err(error) = crate::ui::presets::write_theme_file(&theme) {
|
|
crate::ui::host_ops::HostOps::notify_err(
|
|
window,
|
|
cx,
|
|
t(L10nKey::ThemeSaveFailed),
|
|
&error,
|
|
);
|
|
return;
|
|
}
|
|
crate::ui::presets::load_registry(cx);
|
|
}
|
|
apply_theme(Some(window), cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn edit_active_theme(
|
|
&mut self,
|
|
edit: ThemeEdit,
|
|
value: gpui::Hsla,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let c = hsla_to_u32(value);
|
|
self.mutate_active_theme(
|
|
|theme| match edit {
|
|
ThemeEdit::Background => theme.background = Fill::Solid(c),
|
|
ThemeEdit::Foreground => theme.foreground = c,
|
|
ThemeEdit::Accent => theme.accent = c,
|
|
ThemeEdit::Cursor => theme.caret = Some(c),
|
|
ThemeEdit::Selection => theme.selection = Some(c),
|
|
ThemeEdit::Ansi(i) => theme.ansi16[i] = ((c >> 16) as u8, (c >> 8) as u8, c as u8),
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn effective_window_opacity(cx: &App) -> f32 {
|
|
let config = cx.global::<Config>();
|
|
let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
|
|
let blur = config.window_blur.unwrap_or(theme.blur);
|
|
config.window_opacity.or(theme.opacity).unwrap_or_else(|| {
|
|
crate::ui::theme::default_window_opacity(config.window_backdrop, blur)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn set_window_opacity(
|
|
&mut self,
|
|
v: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
cx.global_mut::<Config>().window_opacity = Some(v.clamp(0.2, 1.0));
|
|
apply_theme(Some(window), cx);
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_window_blur(
|
|
&mut self,
|
|
on: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
cx.global_mut::<Config>().window_blur = Some(on);
|
|
apply_theme(Some(window), cx);
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
pub(crate) fn set_window_backdrop(
|
|
&mut self,
|
|
backdrop: WindowBackdrop,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
cx.global_mut::<Config>().window_backdrop = backdrop;
|
|
apply_theme(Some(window), cx);
|
|
self.persist_settings_config(cx);
|
|
// A material changes the default opacity (SYSTEM_MATERIAL_OPACITY
|
|
// vs 1.0), so the slider must track the new effective value.
|
|
self.sync_window_opacity_slider(window, cx);
|
|
// Rebuild the rows as well as the selected index. The previous value
|
|
// may have been an unsupported preset retained only for cross-machine
|
|
// config sync, and must disappear after the user selects a supported
|
|
// preset on this machine.
|
|
self.sync_window_backdrop_select(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn reset_window_overrides(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
{
|
|
let config = cx.global_mut::<Config>();
|
|
clear_window_override_values(config, cfg!(target_os = "windows"));
|
|
}
|
|
apply_theme(Some(window), cx);
|
|
self.persist_settings_config(cx);
|
|
self.sync_window_opacity_slider(window, cx);
|
|
#[cfg(target_os = "windows")]
|
|
self.sync_window_backdrop_select(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn sync_window_opacity_slider(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let eff = Self::effective_window_opacity(cx);
|
|
if let Some(slider) = self
|
|
.active_settings()
|
|
.map(|s| s.window_opacity_slider.clone())
|
|
{
|
|
slider.update(cx, |s, cx| s.set_value(eff, window, cx));
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
pub(crate) fn sync_window_backdrop_select(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(select) = self
|
|
.active_settings()
|
|
.map(|s| s.window_backdrop_select.clone())
|
|
{
|
|
let current = cx.global::<Config>().window_backdrop;
|
|
let rows = window_backdrop_labels(current);
|
|
let selected = window_backdrop_index(current);
|
|
select.update(cx, |state, cx| {
|
|
state.set_items(SearchableVec::new(rows), window, cx);
|
|
// Replacing the delegate clears its selection snapshot, so
|
|
// restore the stored value after installing the new rows.
|
|
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) fn pick_theme_image(&mut self, cx: &mut Context<Self>) {
|
|
let rx = cx.prompt_for_paths(gpui::PathPromptOptions {
|
|
files: true,
|
|
directories: false,
|
|
multiple: false,
|
|
prompt: None,
|
|
});
|
|
cx.spawn(async move |this, cx| {
|
|
if let Ok(Ok(Some(paths))) = rx.await {
|
|
if let Some(path) = paths.into_iter().next() {
|
|
let _ = this.update_in(cx, |this, window, cx| {
|
|
this.mutate_active_theme(
|
|
|theme| {
|
|
let opacity =
|
|
theme.image.as_ref().map(|i| i.opacity).unwrap_or(0.3);
|
|
theme.image = Some(crate::ui::presets::Image { path, opacity });
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
this.rebuild_theme_editor(window, cx);
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn remove_theme_image(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.mutate_active_theme(|theme| theme.image = None, window, cx);
|
|
self.rebuild_theme_editor(window, cx);
|
|
}
|
|
|
|
pub(crate) fn set_theme_image_opacity(
|
|
&mut self,
|
|
v: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.mutate_active_theme(
|
|
|theme| {
|
|
if let Some(img) = theme.image.as_mut() {
|
|
img.opacity = v.clamp(0.0, 1.0);
|
|
}
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn rebuild_theme_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.settings.is_none() {
|
|
return;
|
|
}
|
|
let id = crate::ui::theme::effective_preset_id(cx);
|
|
let theme = crate::ui::presets::by_id(cx, &id);
|
|
if !theme.editable() {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
s.theme_editor = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let neutrals = theme.neutrals();
|
|
let seed_specs: [(ThemeEdit, u32); 5] = [
|
|
(ThemeEdit::Background, theme.background_color()),
|
|
(ThemeEdit::Foreground, theme.foreground),
|
|
(ThemeEdit::Accent, theme.accent),
|
|
(ThemeEdit::Cursor, theme.caret.unwrap_or(theme.accent)),
|
|
(ThemeEdit::Selection, neutrals.selection),
|
|
];
|
|
|
|
let mut subs = Vec::new();
|
|
let mut make =
|
|
|edit: ThemeEdit, value: u32, subs: &mut Vec<Subscription>, cx: &mut Context<Self>| {
|
|
let eff: gpui::Hsla = gpui::rgb(value).into();
|
|
let state = cx.new(|cx| ColorPickerState::new(window, cx).default_value(eff));
|
|
subs.push(cx.subscribe_in(
|
|
&state,
|
|
window,
|
|
move |this, _picker, ev: &ColorPickerEvent, window, cx| {
|
|
let ColorPickerEvent::Change(value) = ev;
|
|
if let Some(v) = value {
|
|
this.edit_active_theme(edit, *v, window, cx);
|
|
}
|
|
},
|
|
));
|
|
state
|
|
};
|
|
|
|
let seed = seed_specs
|
|
.iter()
|
|
.map(|&(edit, value)| (edit, make(edit, value, &mut subs, cx)))
|
|
.collect();
|
|
let ansi = (0..16)
|
|
.map(|i| {
|
|
let (r, g, b) = theme.ansi16[i];
|
|
let value = (r as u32) << 16 | (g as u32) << 8 | b as u32;
|
|
(
|
|
ThemeEdit::Ansi(i),
|
|
make(ThemeEdit::Ansi(i), value, &mut subs, cx),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let image_opacity_slider = theme.image.as_ref().map(|img| {
|
|
let slider = cx.new(|_| {
|
|
SliderState::new()
|
|
.min(0.0)
|
|
.max(1.0)
|
|
.step(0.01)
|
|
.default_value(img.opacity)
|
|
});
|
|
subs.push(cx.subscribe_in(
|
|
&slider,
|
|
window,
|
|
|this, _s, ev: &SliderEvent, window, cx| {
|
|
if let SliderEvent::Change(v) = ev {
|
|
this.set_theme_image_opacity(v.start(), window, cx);
|
|
}
|
|
},
|
|
));
|
|
slider
|
|
});
|
|
|
|
if let Some(s) = self.settings.as_mut() {
|
|
s.theme_editor = Some(ThemeEditor {
|
|
for_id: theme.id.clone(),
|
|
seed,
|
|
ansi,
|
|
image_opacity_slider,
|
|
_subs: subs,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
let features = on.then(|| {
|
|
crate::core::config::FontFeatures(Arc::new(vec![
|
|
("calt".to_string(), 1),
|
|
("liga".to_string(), 1),
|
|
]))
|
|
});
|
|
let gpui_features = features
|
|
.as_ref()
|
|
.map(crate::core::config::gpui_font_features);
|
|
self.font_features = gpui_features.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let features = gpui_features.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
|
|
}
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.font_features = features;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn apply_terminal_config_to_panes(&self, config: &Config, cx: &mut Context<Self>) {
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.terminal.apply_user_config(config);
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_cursor_style(&mut self, style: ConfigCursorStyle, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.cursor_style = style);
|
|
let cfg = cx.global::<Config>().clone();
|
|
self.terminal_cursor_style = cfg.cursor_style;
|
|
self.terminal_scrollback_limit = cfg.scrollback_limit;
|
|
self.apply_terminal_config_to_panes(&cfg, cx);
|
|
}
|
|
|
|
pub(crate) fn persist_settings_config(&mut self, cx: &mut Context<Self>) {
|
|
let config = cx.global::<Config>().clone();
|
|
let error = config.try_save().err().map(|error| error.to_string());
|
|
if let Some(message) = &error {
|
|
log::warn!("failed to save settings: {message}");
|
|
}
|
|
if let Some(s) = self.active_settings_mut() {
|
|
if error.is_none() {
|
|
s.saved_config = config;
|
|
}
|
|
s.save_error = error;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn discard_unsaved_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let snapshot = self
|
|
.active_settings()
|
|
.filter(|s| s.save_error.is_some())
|
|
.map(|s| s.saved_config.clone());
|
|
if let Some(snapshot) = snapshot {
|
|
cx.set_global(snapshot);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.save_error = None;
|
|
}
|
|
self.reload_from_config(window, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update_config(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
mutate: impl FnOnce(&mut Config),
|
|
) {
|
|
mutate(cx.global_mut::<Config>());
|
|
self.persist_settings_config(cx);
|
|
}
|
|
|
|
pub(crate) fn set_link_url(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.link_url = on);
|
|
}
|
|
|
|
pub(crate) fn set_link_file_open(
|
|
&mut self,
|
|
mode: crate::core::config::LinkFileOpen,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.link_file_open = Some(mode));
|
|
}
|
|
|
|
pub(crate) fn set_ssh_loopback_forward(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.ssh_loopback_forward = on);
|
|
}
|
|
|
|
pub(crate) fn set_verify_host_keys(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.verify_host_keys = on);
|
|
}
|
|
|
|
pub(crate) fn set_ssh_warn_on_close(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
|
|
}
|
|
|
|
pub(crate) fn forward_route(&self, pane_id: u64, cx: &gpui::App) -> ForwardRoute {
|
|
let workspace = self
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|tab| tab.pane.terminals())
|
|
.find_map(|leaf| {
|
|
let view = leaf.read(cx);
|
|
(view.pane_id == pane_id).then(|| view.workspace().cloned())?
|
|
});
|
|
ForwardRoute { pane_id, workspace }
|
|
}
|
|
|
|
pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context<Self>) {
|
|
self.loopback_panel.managed = self.forward_route(pane_id, cx).list();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_managed_forward_kind(
|
|
&mut self,
|
|
kind: crate::daemon::protocol::SshForwardKind,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.loopback_panel.mf_kind = kind;
|
|
cx.notify();
|
|
}
|
|
|
|
/// The managed-forward form's fields as plain text, for the two callers
|
|
/// that have to agree on what they add up to.
|
|
pub(crate) fn managed_forward_fields(&self, cx: &gpui::App) -> ForwardFields {
|
|
let val = |input: &Entity<InputState>| input.read(cx).value().to_string();
|
|
ForwardFields {
|
|
advanced: self.loopback_panel.mf_advanced,
|
|
kind: self.loopback_panel.mf_kind,
|
|
bind_host: val(&self.loopback_panel.mf_bind_host),
|
|
bind_port: val(&self.loopback_panel.mf_bind_port),
|
|
target_host: val(&self.loopback_panel.mf_target_host),
|
|
target_port: val(&self.loopback_panel.mf_target_port),
|
|
description: val(&self.loopback_panel.mf_description),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn add_managed_forward(
|
|
&mut self,
|
|
pane_id: u64,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let fields = self.managed_forward_fields(cx);
|
|
let Some(rule) = fields.collect() else {
|
|
// Add is disabled while the fields do not make a rule and the form
|
|
// already says what is missing, so there is nothing to do here and
|
|
// nothing left to explain.
|
|
return;
|
|
};
|
|
let route = self.forward_route(pane_id, cx);
|
|
let previous = self.loopback_panel.mf_editing.clone();
|
|
// A saved edit is a replace, and the rule being replaced has to come
|
|
// out first: the ordinary edit keeps the bind port, and the far side
|
|
// really does bind it, so adding first would collide with the very
|
|
// rule it is replacing and fail every edit that only renames a rule or
|
|
// moves its target.
|
|
if let Some(old) = &previous {
|
|
let Some(list) = route.remove(old.id) else {
|
|
// Nothing came back, so what the far side still has is
|
|
// unknown — most likely the old rule, still listening. Adding
|
|
// on top of that would collide with it, and putting it back
|
|
// afterwards would leave two of it. Stop while nothing has
|
|
// changed.
|
|
self.loopback_panel.mf_error = Some(t(L10nKey::ForwardRequestFailed).to_string());
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.loopback_panel.managed = list;
|
|
}
|
|
|
|
// The short form aims at the same number on both ends because that is
|
|
// the address people can predict. When it is already taken here, the
|
|
// useful answer is another port rather than a complaint: somebody who
|
|
// typed one number to forward one port has not been asked to care
|
|
// which local port it lands on, and the row says where it came out.
|
|
let retry_free_port = !fields.advanced && rule.bind_port != 0;
|
|
let mut failure = match self.place_forward(&route, rule.clone()) {
|
|
PlaceOutcome::Placed => None,
|
|
// Nobody answered, so nothing was bound and nothing would be bound
|
|
// by asking again — a second round trip would only spend another
|
|
// timeout on the way to the same sentence.
|
|
PlaceOutcome::Unreachable(msg) => Some(msg),
|
|
PlaceOutcome::Rejected(msg) if !retry_free_port => Some(msg),
|
|
PlaceOutcome::Rejected(_) => {
|
|
match self.place_forward(
|
|
&route,
|
|
crate::daemon::protocol::SshForwardRule {
|
|
bind_port: 0,
|
|
..rule.clone()
|
|
},
|
|
) {
|
|
PlaceOutcome::Placed => None,
|
|
PlaceOutcome::Rejected(msg) | PlaceOutcome::Unreachable(msg) => Some(msg),
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(msg) = failure {
|
|
// Put back what the edit took out, so the worst a failed Save can
|
|
// do is leave everything exactly as it was — with the form still
|
|
// open on the rule and the reason underneath it.
|
|
if let Some(old) = &previous {
|
|
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
|
|
if let Some(list) = route.add(rule_of(old)) {
|
|
// The rule comes back under a new id and the form is still
|
|
// editing it, so the form has to be pointed at the entry
|
|
// that now exists — otherwise the next Save would remove
|
|
// an id nobody has and add a second copy of the rule.
|
|
if let Some(restored) = added_forward(&before, &list) {
|
|
self.loopback_panel.mf_editing = Some(restored.clone());
|
|
}
|
|
self.loopback_panel.managed = list;
|
|
}
|
|
}
|
|
self.loopback_panel.mf_error = Some(msg);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
self.loopback_panel.mf_editing = None;
|
|
self.loopback_panel.mf_error = None;
|
|
self.loopback_panel.form_pane_id = None;
|
|
for input in [
|
|
&self.loopback_panel.mf_bind_port,
|
|
&self.loopback_panel.mf_target_host,
|
|
&self.loopback_panel.mf_target_port,
|
|
&self.loopback_panel.mf_description,
|
|
] {
|
|
input.update(cx, |input, cx| input.set_value("", window, cx));
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// Ask the far side for one rule, and say what became of it.
|
|
///
|
|
/// A rule that could not be started is registered all the same, with the
|
|
/// reason in its status, so whether the add worked is a question about the
|
|
/// entry it appended rather than about whether the call returned. The dead
|
|
/// entry is taken back out — a forward listed as listening on nothing is
|
|
/// worse than no forward.
|
|
fn place_forward(
|
|
&mut self,
|
|
route: &ForwardRoute,
|
|
rule: crate::daemon::protocol::SshForwardRule,
|
|
) -> PlaceOutcome {
|
|
use crate::daemon::protocol::ForwardStatus;
|
|
|
|
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
|
|
// The request never got an answer. An empty list here is not "this
|
|
// pane has no forwards", it is "nobody said" — assigning it is what
|
|
// used to blank the panel on a dropped connection.
|
|
let Some(list) = route.add(rule) else {
|
|
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
|
|
};
|
|
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
|
|
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
|
|
ForwardStatus::Listening => None,
|
|
});
|
|
self.loopback_panel.managed = list;
|
|
let Some((id, msg)) = broken else {
|
|
return PlaceOutcome::Placed;
|
|
};
|
|
if let Some(list) = route.remove(id) {
|
|
self.loopback_panel.managed = list;
|
|
}
|
|
PlaceOutcome::Rejected(msg)
|
|
}
|
|
|
|
pub(crate) fn edit_managed_forward(
|
|
&mut self,
|
|
forward: crate::daemon::protocol::ManagedForward,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.loopback_panel.mf_kind = forward.kind;
|
|
self.loopback_panel.form_pane_id = Some(forward.pane_id);
|
|
self.loopback_panel.mf_error = None;
|
|
// A rule that already exists is shown whole: the short form cannot
|
|
// spell a bind host or a remote forward, so editing one through it
|
|
// would silently rewrite the parts it cannot see.
|
|
self.loopback_panel.mf_advanced = true;
|
|
let target_port = if forward.target_port == 0 {
|
|
String::new()
|
|
} else {
|
|
forward.target_port.to_string()
|
|
};
|
|
let fields: [(&Entity<InputState>, String); 5] = [
|
|
(&self.loopback_panel.mf_bind_host, forward.bind_host.clone()),
|
|
(
|
|
&self.loopback_panel.mf_bind_port,
|
|
forward.bind_port.to_string(),
|
|
),
|
|
(
|
|
&self.loopback_panel.mf_target_host,
|
|
forward.target_host.clone(),
|
|
),
|
|
(&self.loopback_panel.mf_target_port, target_port),
|
|
(
|
|
&self.loopback_panel.mf_description,
|
|
forward.description.clone().unwrap_or_default(),
|
|
),
|
|
];
|
|
for (input, value) in fields {
|
|
input.update(cx, |input, cx| input.set_value(&value, window, cx));
|
|
}
|
|
self.loopback_panel.mf_editing = Some(forward);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn cancel_managed_forward_edit(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.loopback_panel.mf_editing = None;
|
|
self.loopback_panel.mf_error = None;
|
|
for input in [
|
|
&self.loopback_panel.mf_bind_port,
|
|
&self.loopback_panel.mf_target_host,
|
|
&self.loopback_panel.mf_target_port,
|
|
&self.loopback_panel.mf_description,
|
|
] {
|
|
input.update(cx, |input, cx| input.set_value("", window, cx));
|
|
}
|
|
self.loopback_panel
|
|
.mf_bind_host
|
|
.update(cx, |input, cx| input.set_value("127.0.0.1", window, cx));
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn remove_managed_forward(
|
|
&mut self,
|
|
pane_id: u64,
|
|
forward_id: u64,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Only what the far side actually answered with. A request that never
|
|
// got a reply knows nothing about the remaining forwards, and writing
|
|
// its empty list into the panel would blank a list that is still there.
|
|
if let Some(list) = self.forward_route(pane_id, cx).remove(forward_id) {
|
|
self.loopback_panel.managed = list;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Whatever the panel would let this pane forward, which is a wider set
|
|
// than "a connected native-ssh pane": a pane in a remote workspace
|
|
// forwards over the workspace's own connection, and the command used
|
|
// to do nothing at all there while the panel beside it worked.
|
|
let Some(ctx) = self.pane_forward_ctx(window, cx) else {
|
|
return;
|
|
};
|
|
if ctx.route.is_none() {
|
|
return;
|
|
}
|
|
self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx);
|
|
if self.loopback_panel.form_pane_id != Some(ctx.pane_id) {
|
|
self.toggle_managed_forward_form(ctx.pane_id, window, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn toggle_managed_forward_form(
|
|
&mut self,
|
|
pane_id: u64,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.loopback_panel.form_pane_id == Some(pane_id) {
|
|
self.close_managed_forward_form(window, cx);
|
|
return;
|
|
}
|
|
self.loopback_panel.form_pane_id = Some(pane_id);
|
|
self.loopback_panel.mf_advanced = false;
|
|
self.loopback_panel.mf_kind = crate::daemon::protocol::SshForwardKind::Local;
|
|
self.cancel_managed_forward_edit(window, cx);
|
|
self.refresh_managed_forwards(pane_id, cx);
|
|
self.arm_managed_forward_form(pane_id, window, cx);
|
|
}
|
|
|
|
/// Opens the form focused and listening for Return.
|
|
///
|
|
/// It had neither. Every other form in the app opens with the caret in the
|
|
/// first field and answers Return — this one opened cold, so adding a rule
|
|
/// meant clicking into Bind first, and once you were there the only way to
|
|
/// commit was the mouse again. Escape did nothing either, which is handled
|
|
/// on the form itself in `forwards.rs`; a key event only reaches it while
|
|
/// something inside it holds focus, so the focus below is what makes that
|
|
/// work too.
|
|
fn arm_managed_forward_form(
|
|
&mut self,
|
|
pane_id: u64,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let inputs = [
|
|
self.loopback_panel.mf_bind_host.clone(),
|
|
self.loopback_panel.mf_bind_port.clone(),
|
|
self.loopback_panel.mf_target_host.clone(),
|
|
self.loopback_panel.mf_target_port.clone(),
|
|
self.loopback_panel.mf_description.clone(),
|
|
];
|
|
self.loopback_panel.mf_subs = inputs
|
|
.iter()
|
|
.map(|input| {
|
|
cx.subscribe_in(
|
|
input,
|
|
window,
|
|
move |this, _input, ev: &InputEvent, window, cx| {
|
|
if let InputEvent::PressEnter { .. } = ev {
|
|
// A no-op when the fields do not make a rule yet:
|
|
// `add_managed_forward` already guards on that and
|
|
// the form already says what is missing.
|
|
this.add_managed_forward(pane_id, window, cx);
|
|
}
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
inputs[0].update(cx, |s, cx| s.focus(window, cx));
|
|
}
|
|
|
|
pub(crate) fn toggle_managed_forward_advanced(&mut self, cx: &mut Context<Self>) {
|
|
self.loopback_panel.mf_advanced = !self.loopback_panel.mf_advanced;
|
|
self.loopback_panel.mf_error = None;
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn close_managed_forward_form(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let was_open = self.loopback_panel.form_pane_id.take().is_some();
|
|
self.loopback_panel.mf_subs.clear();
|
|
self.cancel_managed_forward_edit(window, cx);
|
|
if was_open {
|
|
// The form held the focus, so taking it down has to hand it back —
|
|
// otherwise the next keystroke goes nowhere until the user clicks.
|
|
self.focus_active(window, cx);
|
|
}
|
|
}
|
|
|
|
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
match parse_ssh_connect_input(input) {
|
|
Ok(parsed) => {
|
|
let (profile, proxy_jump) =
|
|
match ssh_config::resolve_alias_to_profile(&parsed.profile.host) {
|
|
Some(resolved) => {
|
|
let mut p = resolved.profile;
|
|
if !parsed.profile.user.is_empty() {
|
|
p.user = parsed.profile.user;
|
|
}
|
|
if parsed.profile.port != 22 {
|
|
p.port = parsed.profile.port;
|
|
}
|
|
if !parsed.profile.identity_files.is_empty() {
|
|
p.identity_files = parsed.profile.identity_files;
|
|
}
|
|
(p, parsed.proxy_jump.or(resolved.proxy_jump))
|
|
}
|
|
None => (parsed.profile, parsed.proxy_jump),
|
|
};
|
|
let verify = cx.global::<Config>().verify_host_keys;
|
|
let spec = crate::ui::ssh_connect::native_spec_from_transient_profile(
|
|
&profile,
|
|
proxy_jump,
|
|
&crate::core::keychain::OsCredentialStore,
|
|
verify,
|
|
&crate::ui::ssh_connect::config_alias_resolver,
|
|
);
|
|
self.open_native_ssh_tab(Box::new(spec), window, cx);
|
|
}
|
|
Err(reason) => self.push_ssh_connect_error(reason, cx),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_check_for_updates(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.check_for_updates = on);
|
|
}
|
|
|
|
pub(crate) fn set_auto_download_updates(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.auto_download_updates = on);
|
|
}
|
|
|
|
/// Moves this installation to another release feed. See
|
|
/// `update::switch_channel` for what a switch invalidates, and why moving
|
|
/// back to Stable does not roll the running build back.
|
|
pub(crate) fn set_update_channel(
|
|
&mut self,
|
|
channel: crate::core::config::UpdateChannel,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if cx.global::<Config>().update_channel == channel {
|
|
return;
|
|
}
|
|
self.update_config(cx, |cfg| cfg.update_channel = channel);
|
|
crate::core::update::switch_channel(cx);
|
|
}
|
|
|
|
/// Takes effect at next launch: `core::cli_install` runs once from `main`,
|
|
/// before there is a window to flip this in. Turning it off does not remove
|
|
/// a symlink already placed — the install is idempotent, not reversible.
|
|
pub(crate) fn set_install_cli_on_path(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.install_cli_on_path = on);
|
|
}
|
|
|
|
pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
|
|
}
|
|
|
|
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.cursor_blink = on);
|
|
if !on {
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.cursor_visible = true;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_scrollback_limit(&mut self, lines: usize, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| {
|
|
cfg.scrollback_limit = lines.clamp(100, crate::core::config::MAX_SCROLLBACK)
|
|
});
|
|
let cfg = cx.global::<Config>().clone();
|
|
self.terminal_cursor_style = cfg.cursor_style;
|
|
self.terminal_scrollback_limit = cfg.scrollback_limit;
|
|
self.apply_terminal_config_to_panes(&cfg, cx);
|
|
}
|
|
|
|
pub(crate) fn set_new_tab_position(&mut self, pos: NewTabPosition, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.new_tab_position = pos);
|
|
}
|
|
|
|
pub(crate) fn set_tab_bar_position(&mut self, pos: TabBarPosition, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.tab_bar_position = pos);
|
|
}
|
|
|
|
pub(crate) fn set_sidebar_grouping(
|
|
&mut self,
|
|
grouping: crate::core::config::SidebarGrouping,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping);
|
|
}
|
|
|
|
pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on);
|
|
}
|
|
|
|
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
|
|
let next = match cx.global::<Config>().tab_bar_position {
|
|
TabBarPosition::Top => TabBarPosition::Left,
|
|
TabBarPosition::Left => TabBarPosition::Top,
|
|
};
|
|
self.set_tab_bar_position(next, cx);
|
|
}
|
|
|
|
pub(crate) fn toggle_left_panel(&mut self, cx: &mut Context<Self>) {
|
|
let (pos, collapsed) = match cx.global::<Config>().tab_bar_position {
|
|
TabBarPosition::Top => (TabBarPosition::Left, false),
|
|
TabBarPosition::Left => (TabBarPosition::Left, !self.sidebar_collapsed),
|
|
};
|
|
self.sidebar_collapsed = collapsed;
|
|
self.update_config(cx, |cfg| {
|
|
cfg.tab_bar_position = pos;
|
|
cfg.sidebar_collapsed = collapsed;
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn left_panel_open(&self, cx: &gpui::App) -> bool {
|
|
matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
|
|
&& !self.sidebar_collapsed
|
|
&& !self.tabs.is_empty()
|
|
}
|
|
|
|
pub(crate) fn set_notify_mode(
|
|
&mut self,
|
|
mode: crate::core::config::NotifyMode,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.notify_on_command_finish = mode);
|
|
}
|
|
|
|
pub(crate) fn set_notify_threshold(&mut self, secs: u64, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.notify_threshold_secs = secs.clamp(1, 3600));
|
|
}
|
|
|
|
pub(crate) fn set_bell_mode(
|
|
&mut self,
|
|
mode: crate::core::config::BellMode,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.bell = mode);
|
|
}
|
|
|
|
pub(crate) fn set_restore_session(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.restore_session = on);
|
|
}
|
|
|
|
/// Takes effect on the next pane: a shell is told where its history lives
|
|
/// when it starts, and nothing can move it afterwards.
|
|
pub(crate) fn set_per_pane_history(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.per_pane_history = on);
|
|
}
|
|
|
|
pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.show_tray_icon = on);
|
|
}
|
|
|
|
pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.macos_option_as_alt = on);
|
|
}
|
|
|
|
pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on);
|
|
crate::ui::theme::apply_cursor_hide_mode(cx);
|
|
}
|
|
|
|
pub(crate) fn set_focus_follows_mouse(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.focus_follows_mouse = on);
|
|
}
|
|
|
|
pub(crate) fn set_mouse_reporting(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.mouse_reporting = on);
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.report_mouse = on;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_mouse_scroll_multiplier(&mut self, mult: f32, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| {
|
|
cfg.mouse_scroll_multiplier = mult.clamp(0.1, 10.0)
|
|
});
|
|
}
|
|
|
|
pub(crate) fn set_smooth_scroll(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.smooth_scroll = on);
|
|
}
|
|
|
|
/// Panes read the modifier off the global config as each wheel event
|
|
/// arrives, so there is nothing to push at them here.
|
|
pub(crate) fn set_mouse_zoom_modifier(
|
|
&mut self,
|
|
modifier: MouseZoomModifier,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.mouse_zoom_modifier = modifier);
|
|
}
|
|
|
|
pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on);
|
|
}
|
|
|
|
pub(crate) fn set_copy_on_select(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.copy_on_select = on);
|
|
}
|
|
|
|
pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.smart_select = on);
|
|
}
|
|
|
|
/// Hands the prompt to the shell's own line editor, or takes it back.
|
|
///
|
|
/// Live panes carry a cached copy of the flag (see
|
|
/// [`crate::terminal::view::TerminalView::prompt_editor`]), so the switch
|
|
/// has to reach them: without this, only panes opened afterwards would
|
|
/// change hands.
|
|
pub(crate) fn set_prompt_editor(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.prompt_editor = on);
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| v.set_prompt_editor(on, cx));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.tab_completion = on);
|
|
}
|
|
|
|
pub(crate) fn set_history_search(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.history_search = on);
|
|
}
|
|
|
|
pub(crate) fn set_startup_mode(
|
|
&mut self,
|
|
mode: crate::core::config::StartupMode,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_config(cx, |cfg| cfg.startup_mode = mode);
|
|
}
|
|
|
|
pub(crate) fn set_remember_window_size(&mut self, on: bool, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.remember_window_size = on);
|
|
}
|
|
|
|
pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) {
|
|
let title = WorkspaceStore::all(cx)
|
|
.get(self.workspace)
|
|
.filter(|w| crate::ui::machine_mirror::pane_count(cx, w).unwrap_or(0) > 0)
|
|
.and_then(|w| crate::ui::machine_mirror::display_name(cx, w))
|
|
.unwrap_or_else(|| "tty7".to_string());
|
|
if *self.window_title.borrow() == title {
|
|
return;
|
|
}
|
|
window.set_window_title(&title);
|
|
*self.window_title.borrow_mut() = title;
|
|
}
|
|
|
|
pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) {
|
|
self.sync_window_title(window, cx);
|
|
if let Some(settings) = self.settings.as_ref() {
|
|
window.focus(&settings.focus_handle, cx);
|
|
return;
|
|
}
|
|
let Some(tab) = self.tabs.get(self.active) else {
|
|
window.focus(&self.home_focus, cx);
|
|
return;
|
|
};
|
|
if let Some(overlay) = tab.diff_overlay.as_ref() {
|
|
window.focus(&overlay.focus_handle, cx);
|
|
return;
|
|
}
|
|
if let Some(leaf) = tab.focus_target() {
|
|
let handle = leaf.focus_handle(cx);
|
|
window.focus(&handle, cx);
|
|
}
|
|
}
|
|
|
|
/// Sample which pane holds focus right now and record it against the
|
|
/// active tab.
|
|
///
|
|
/// A sample only ever writes the truth, but it can only write it when
|
|
/// there is one to read: with focus one handle off the panes it finds no
|
|
/// leaf and leaves the field alone. That is why it is no longer the only
|
|
/// writer (see [`Tty7App::remember_focused_leaf`]) — it stays because the
|
|
/// callers below want the answer settled at a named moment, before a pane
|
|
/// is detached or a tab is torn down and the layout stops being able to
|
|
/// answer at all.
|
|
pub(crate) fn remember_active_pane(&mut self, window: &Window, cx: &App) {
|
|
let active = self.active;
|
|
if let Some(tab) = self.tabs.get_mut(active) {
|
|
if let Some(leaf) = tab.pane.focused_leaf(window, cx) {
|
|
tab.last_focused = Some(leaf.entity_id());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record `leaf` as the pane its tab comes back to, as focus arrives in it.
|
|
///
|
|
/// Sampling at switch time asks which leaf holds focus *at that instant*,
|
|
/// and by then focus is routinely somewhere else: the switcher's own
|
|
/// search input, a palette that just closed, the tab strip, a pane
|
|
/// restored and never clicked. The sample then wrote nothing and the tab
|
|
/// kept a stale pane — or the `None` it was born with — and came back to
|
|
/// its first leaf instead of the one the reader was working in (#843).
|
|
///
|
|
/// Focus-in is the one moment that knows the answer without having to
|
|
/// guess when to look, so it is the primary writer now. The tab is found
|
|
/// by the leaf rather than assumed to be the active one: a pane dragged
|
|
/// into another tab is focused after the move, and it is the tab holding
|
|
/// it now that has to remember it.
|
|
pub(crate) fn remember_focused_leaf(&mut self, leaf: gpui::EntityId) {
|
|
remember_leaf_in(&mut self.tabs, leaf);
|
|
}
|
|
|
|
/// Toggle fullscreen, and say how to leave it on the way in.
|
|
///
|
|
/// Only on the way in, and only from the action: entering is an instant in
|
|
/// which the window buttons disappear, and a window that starts fullscreen
|
|
/// because the setting says so is not a surprise anybody needs explaining.
|
|
/// The chord comes from the keymap rather than from a string, because it is
|
|
/// `F11` on Windows and Linux, `Cmd+Enter` on macOS, and either of them may
|
|
/// have been rebound.
|
|
///
|
|
/// The hint carries an id of its own, which is what keeps a held-down
|
|
/// `F11` to one notice rather than a column of identical ones: pushing
|
|
/// under an id already on screen replaces that one. Leaving through the
|
|
/// action takes it back too, so a quick in-and-out does not leave the way
|
|
/// out on screen after it has been taken. Leaving some other way — a
|
|
/// window manager with a chord of its own — just lets it time out, which
|
|
/// is a second or two of a stale notice and not worth watching every
|
|
/// frame for.
|
|
fn toggle_fullscreen(&self, window: &mut Window, cx: &mut App) {
|
|
let entering = !window.is_fullscreen();
|
|
window.toggle_fullscreen();
|
|
window.remove_notification::<FullscreenHint>(cx);
|
|
// Nothing disappeared where there were no buttons to begin with, so
|
|
// there is nothing to explain.
|
|
if !entering || !window_controls_drawn(false) {
|
|
return;
|
|
}
|
|
let hint = match crate::ui::home::key_hint("ToggleFullscreen", cx) {
|
|
Some(chord) => t_fmt(L10nKey::AppFullscreenEntered, &[("key", &chord)]),
|
|
// Rebound to nothing at all: still worth saying the buttons are gone,
|
|
// just without naming a key that would not work.
|
|
None => t(L10nKey::AppFullscreenEnteredNoKey).to_string(),
|
|
};
|
|
window.push_notification(
|
|
gpui_component::notification::Notification::new()
|
|
.id::<FullscreenHint>()
|
|
.message(hint),
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn focus_leaf(&self, leaf: &PaneSlot, window: &mut Window, cx: &mut App) {
|
|
let handle = leaf.focus_handle(cx);
|
|
window.focus(&handle, cx);
|
|
}
|
|
|
|
fn land_pane(
|
|
&mut self,
|
|
slot_id: gpui::EntityId,
|
|
pending: &Entity<crate::ui::pending_pane::PendingPane>,
|
|
parts: Result<crate::terminal::view::ShellParts, String>,
|
|
font_size: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let parts = match parts {
|
|
Ok(parts) => parts,
|
|
Err(reason) => {
|
|
pending.update(cx, |p, cx| p.fail(reason, cx));
|
|
return;
|
|
}
|
|
};
|
|
let still_there = self
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == slot_id));
|
|
if !still_there {
|
|
log::info!(
|
|
"pane {} arrived after its slot closed; killing it",
|
|
parts.pane_id
|
|
);
|
|
let route = crate::terminal::PaneRoute::for_workspace(parts.workspace.as_ref());
|
|
kill_pane_off_thread(route, parts.pane_id, cx);
|
|
return;
|
|
}
|
|
let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx);
|
|
let resume = (!parts.restored)
|
|
.then(|| {
|
|
let spawn = &pending.read(cx).spawn;
|
|
agent_resume_command(
|
|
&spawn.agent,
|
|
spawn.agent_session_id.as_deref(),
|
|
spawn.agent_launch_argv.as_deref(),
|
|
cx,
|
|
)
|
|
})
|
|
.flatten();
|
|
let view = build_terminal_view(parts, font_size, window, cx);
|
|
if let Some(cmd) = resume {
|
|
view.read(cx).run_command_line(&cmd);
|
|
}
|
|
let slot = PaneSlot::Ready(view.clone());
|
|
replace_leaf_in(&mut self.tabs, slot_id, slot.clone());
|
|
if was_focused {
|
|
self.focus_leaf(&slot, window, cx);
|
|
}
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
|
|
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) {
|
|
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
|
|
}
|
|
}
|
|
|
|
fn new_tab_insert_at(&self, cx: &App) -> usize {
|
|
match cx.global::<Config>().new_tab_position {
|
|
NewTabPosition::AfterCurrent => (self.active + 1).min(self.tabs.len()),
|
|
NewTabPosition::End => self.tabs.len(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.new_tab_with_shell(None, window, cx);
|
|
}
|
|
|
|
pub(crate) fn new_tab_at(
|
|
&mut self,
|
|
cwd: std::path::PathBuf,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.new_tab_with_cwd(Some(cwd), None, window, cx);
|
|
}
|
|
|
|
/// Opens a new local shell in `cwd`, then types a command only after the
|
|
/// pane exists. LaunchServices uses this for a script/executable selected
|
|
/// in Finder and for `x-man-page:` requests.
|
|
///
|
|
/// A tab that did not open takes the command with it. `new_tab_with_cwd`
|
|
/// returns early when the spawn fails or the workspace cannot host a local
|
|
/// shell, and writing anyway would type the command into whatever pane was
|
|
/// focused before — a shell the user is mid-line in, or an agent.
|
|
pub(crate) fn new_tab_running(
|
|
&mut self,
|
|
cwd: std::path::PathBuf,
|
|
command: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let before = self.tabs.len();
|
|
self.new_tab_with_cwd(Some(cwd), None, window, cx);
|
|
if self.tabs.len() == before {
|
|
log::warn!("no tab opened for {command:?}; not writing it to another pane");
|
|
return;
|
|
}
|
|
if let Some(terminal) = self.focused_leaf(window, cx) {
|
|
terminal.read(cx).run_command_line(&command);
|
|
}
|
|
}
|
|
|
|
/// Uses the terminal that a newly-created window already opened. This keeps
|
|
/// a cold LaunchServices request to one tab rather than creating the
|
|
/// window's default shell and then a second shell for the requested item.
|
|
pub(crate) fn run_in_active_terminal(&self, command: &str, window: &Window, cx: &App) {
|
|
if let Some(terminal) = self.focused_leaf(window, cx) {
|
|
terminal.read(cx).run_command_line(command);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn new_tab_with_shell(
|
|
&mut self,
|
|
shell: Option<ShellSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let cwd = self.tabs.get(self.active).and_then(|t| {
|
|
t.pane
|
|
.focused_or_first(window, cx)
|
|
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
|
|
});
|
|
self.new_tab_with_cwd(cwd, shell, window, cx);
|
|
}
|
|
|
|
/// A shell taken out of the new-tab menu, wherever the ⌥ key said to put
|
|
/// it.
|
|
pub(crate) fn open_shell(
|
|
&mut self,
|
|
shell: Option<ShellSpec>,
|
|
at: SpawnWhere,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match at {
|
|
SpawnWhere::NewTab => self.new_tab_with_shell(shell, window, cx),
|
|
SpawnWhere::Split => {
|
|
self.split_into(Axis::Horizontal, Some(SpawnAs::Shell(shell)), window, cx)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn new_tab_with_cwd(
|
|
&mut self,
|
|
cwd: Option<std::path::PathBuf>,
|
|
shell: Option<ShellSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if !self.guard_local_spawn(window, cx) {
|
|
return;
|
|
}
|
|
let group = self.spawn_group(cwd.as_deref(), cx);
|
|
let tab = match new_terminal(
|
|
self.window_workspace(cx),
|
|
Some(self.workspace),
|
|
self.font_size,
|
|
cwd,
|
|
None,
|
|
shell,
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("new tab spawn failed: {e}");
|
|
let text = t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]);
|
|
// A retry from the home screen fails the same way; keep the
|
|
// reason on screen rather than only in a toast that leaves.
|
|
self.startup_error = Some(gpui::SharedString::from(text.clone()));
|
|
window.push_notification(text, cx);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
};
|
|
// Something opened, so whatever the last failure was is stale.
|
|
self.startup_error = None;
|
|
self.remember_active_pane(window, cx);
|
|
self.maximized = None;
|
|
let insert_at = self.new_tab_insert_at(cx);
|
|
let new_tab = Tab::new(Pane::leaf(tab));
|
|
if let Some(group) = group {
|
|
*new_tab.sidebar_group.borrow_mut() = group;
|
|
}
|
|
self.tabs.insert(insert_at, new_tab);
|
|
self.active = insert_at;
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn open_native_ssh_tab(
|
|
&mut self,
|
|
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let cwd = self.tabs.get(self.active).and_then(|t| {
|
|
t.pane
|
|
.focused_or_first(window, cx)
|
|
.and_then(|leaf| leaf.read(cx).cwd())
|
|
});
|
|
let view = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("native SSH spawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(
|
|
L10nKey::AppSshConnectionFailed,
|
|
&[("error", &e.to_string())],
|
|
),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
self.remember_active_pane(window, cx);
|
|
self.maximized = None;
|
|
let insert_at = self.new_tab_insert_at(cx);
|
|
self.tabs
|
|
.insert(insert_at, Tab::new(Pane::leaf(PaneSlot::Ready(view))));
|
|
self.active = insert_at;
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn respawn_native_ssh_in_place(
|
|
&mut self,
|
|
dead: &Entity<TerminalView>,
|
|
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let cwd = dead.read(cx).cwd();
|
|
let fresh = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("native SSH respawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppSshReconnectFailed, &[("error", &e.to_string())]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
replace_leaf_in(
|
|
&mut self.tabs,
|
|
dead.entity_id(),
|
|
PaneSlot::Ready(fresh.clone()),
|
|
);
|
|
self.maximized = None;
|
|
self.focus_leaf(&PaneSlot::Ready(fresh), window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.split_into(axis, None, window, cx);
|
|
}
|
|
|
|
/// `spawn` of `None` is what ⌘D has always done: copy the pane being split
|
|
/// — same shell, or the same host dialled again. A `Some` names the new
|
|
/// pane outright, which is how a row taken out of the new-tab menu with ⌥
|
|
/// held lands beside the current pane instead of in a tab of its own.
|
|
pub(crate) fn split_into(
|
|
&mut self,
|
|
axis: Axis,
|
|
spawn: Option<SpawnAs>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(target) = self
|
|
.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx))
|
|
else {
|
|
return;
|
|
};
|
|
if !self.guard_local_spawn(window, cx) {
|
|
return;
|
|
}
|
|
let cwd = target.read(cx).spawnable_cwd();
|
|
let spawn = match spawn {
|
|
Some(spawn) => spawn,
|
|
// A stored spec is resolved against the saved host before it is
|
|
// dialled; one handed in by a caller was just built from that host
|
|
// and needs no second pass.
|
|
None => match target.read(cx).ssh_spec() {
|
|
Some(spec) => {
|
|
SpawnAs::Ssh(crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx))
|
|
}
|
|
None => SpawnAs::Shell(target.read(cx).shell_spec()),
|
|
},
|
|
};
|
|
let new = match spawn {
|
|
SpawnAs::Ssh(spec) => {
|
|
match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
|
Ok(view) => PaneSlot::Ready(view),
|
|
Err(e) => {
|
|
log::error!("native SSH split spawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(
|
|
L10nKey::AppSshConnectionFailed,
|
|
&[("error", &e.to_string())],
|
|
),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
SpawnAs::Shell(shell) => {
|
|
match new_terminal(
|
|
self.window_workspace(cx),
|
|
Some(self.workspace),
|
|
self.font_size,
|
|
cwd,
|
|
None,
|
|
shell,
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("split spawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
|
if tab
|
|
.pane
|
|
.split_leaf(target.entity_id(), axis, false, new.clone())
|
|
{
|
|
self.maximized = None;
|
|
self.focus_leaf(&new, window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.close_pane_inner(false, window, cx);
|
|
}
|
|
|
|
/// `confirmed` is set only by the answer to this pane's own close question,
|
|
/// so it travels with the close rather than being read back off shared
|
|
/// state a second, unrelated close could have overwritten.
|
|
fn close_pane_inner(&mut self, confirmed: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
if !confirmed && let Some(reason) = self.focused_pane_close_reason(window, cx) {
|
|
self.ask_before_closing(CloseTarget::Pane, reason, window, cx);
|
|
return;
|
|
}
|
|
self.maximized = None;
|
|
let focused = self.tabs.get(self.active).and_then(|tab| {
|
|
tab.pane
|
|
.leaves()
|
|
.into_iter()
|
|
.find(|l| l.contains_focused(window, cx))
|
|
.and_then(|l| l.terminal().cloned())
|
|
});
|
|
let outcome = match self.tabs.get_mut(self.active) {
|
|
Some(tab) => tab.pane.close_focused(window, cx),
|
|
None => return,
|
|
};
|
|
match outcome {
|
|
// The last pane takes its tab with it. The question was already
|
|
// asked about this very pane, so carry the answer across rather
|
|
// than letting the tab re-derive the same reason and ask again.
|
|
CloseOutcome::RemoveSelf => {
|
|
self.close_tab_inner(self.active, confirmed, window, cx);
|
|
}
|
|
CloseOutcome::NotFound => {
|
|
let single = self
|
|
.tabs
|
|
.get(self.active)
|
|
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
|
|
if single {
|
|
self.close_tab_inner(self.active, confirmed, window, cx);
|
|
}
|
|
}
|
|
CloseOutcome::Collapsed => {
|
|
if let Some(leaf) = &focused {
|
|
kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx);
|
|
}
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_child_exited(
|
|
&mut self,
|
|
view: Entity<TerminalView>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let id = view.entity_id();
|
|
let Some(index) = self
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == id))
|
|
else {
|
|
return;
|
|
};
|
|
if view.read(cx).ssh_disconnected() {
|
|
cx.notify();
|
|
return;
|
|
}
|
|
match self.tabs[index].pane.close_leaf(view.entity_id()) {
|
|
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
|
|
CloseOutcome::NotFound => {}
|
|
CloseOutcome::Collapsed => {
|
|
kill_pane_off_thread(view.read(cx).pane_route(), view.read(cx).pane_id, cx);
|
|
if index == self.active {
|
|
self.maximized = None;
|
|
self.focus_active(window, cx);
|
|
}
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
let leaves = match self.tabs.get(self.active) {
|
|
Some(tab) => tab.pane.leaves(),
|
|
None => return,
|
|
};
|
|
if leaves.len() < 2 {
|
|
return;
|
|
}
|
|
self.maximized = None;
|
|
let current = leaves
|
|
.iter()
|
|
.position(|l| l.contains_focused(window, cx))
|
|
.unwrap_or(0);
|
|
let next = if forward {
|
|
(current + 1) % leaves.len()
|
|
} else {
|
|
(current + leaves.len() - 1) % leaves.len()
|
|
};
|
|
let leaf = leaves[next].clone();
|
|
self.focus_leaf(&leaf, window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(tab) = self.tabs.get(self.active) else {
|
|
return;
|
|
};
|
|
let Some(from) = tab.pane.focused_leaf(window, cx) else {
|
|
return;
|
|
};
|
|
let back = tab.focus_origin(from.entity_id(), dir);
|
|
let Some(target) = tab.pane.neighbor_in_dir(dir, back, window, cx) else {
|
|
return;
|
|
};
|
|
let live: Vec<gpui::EntityId> = tab.pane.leaves().iter().map(|l| l.entity_id()).collect();
|
|
let (from, to) = (from.entity_id(), target.entity_id());
|
|
let active = self.active;
|
|
if let Some(tab) = self.tabs.get_mut(active) {
|
|
tab.remember_focus_origin(from, to, dir, &live);
|
|
}
|
|
self.maximized = None;
|
|
self.focus_leaf(&target, window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn resize_pane(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
|
|
let changed = self
|
|
.tabs
|
|
.get(self.active)
|
|
.is_some_and(|tab| tab.pane.resize_focused_pane(dir, RESIZE_STEP, window, cx));
|
|
if changed {
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn swap_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
let (from, len) = match self.tabs.get(self.active) {
|
|
Some(tab) => (tab.pane.focused_index(window, cx), tab.pane.leaves().len()),
|
|
None => return,
|
|
};
|
|
if len < 2 {
|
|
return;
|
|
}
|
|
let from = from.unwrap_or(0);
|
|
let to = if forward {
|
|
(from + 1) % len
|
|
} else {
|
|
(from + len - 1) % len
|
|
};
|
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
|
if tab.pane.swap_leaf_indices(from, to) {
|
|
self.maximized = None;
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The patch of the layout a pane being dragged would land on, lit up.
|
|
///
|
|
/// Also records that landing as the one a drop would take, so the drop and
|
|
/// the highlight can never disagree: a zone the tree refuses to carry out
|
|
/// is neither drawn nor remembered, and releasing over it does nothing.
|
|
fn pane_landing(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
|
|
use crate::ui::pane_drag;
|
|
|
|
let from = pane_drag::lifted(&self.pane_drag)?;
|
|
let area = self.pane_area.get()?;
|
|
let tab = self.tabs.get(self.active)?;
|
|
let leaves = tab.pane.leaves();
|
|
let slot = leaves.iter().find(|l| l.entity_id() == from)?;
|
|
let bounds = pane_drag::leaf_bounds(&tab.pane, area);
|
|
let zone = pane_drag::zone_at(area, &bounds, window.mouse_position())?;
|
|
// The zone comes back naming its target by position, which only means
|
|
// anything against this frame's leaves. Drawn against the panes here,
|
|
// and remembered as the panes so the drop that reads it back a frame
|
|
// later is looking for the same ones.
|
|
let here = zone.map(|i| leaves.get(i).cloned())?;
|
|
let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?;
|
|
let rect = pane_drag::landing(&tab.pane, slot, here, area)?;
|
|
pane_drag::set_landing(&self.pane_drag, pinned);
|
|
|
|
let accent = cx.theme().drag_border;
|
|
Some(
|
|
div()
|
|
.absolute()
|
|
.left(rect.origin.x - area.origin.x)
|
|
.top(rect.origin.y - area.origin.y)
|
|
.w(rect.size.width)
|
|
.h(rect.size.height)
|
|
.rounded(px(6.))
|
|
.border_2()
|
|
.border_color(accent)
|
|
.bg(accent.opacity(0.15))
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
/// The patch of the layout a tab held over it would be grafted into, lit
|
|
/// up — and, while that is on offer, the strip held still underneath it.
|
|
///
|
|
/// A tab is dragged with the same grip that reorders it, so the two
|
|
/// readings share one gesture: over the strip or the sidebar it is a
|
|
/// reorder, out over the panes it is a merge. Suspending the reorder is
|
|
/// what keeps the drop from being both.
|
|
fn tab_landing(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
|
|
self.tab_merge.set(None);
|
|
let dragged = crate::ui::reorder::dragged_tab(&self.reorder);
|
|
let offer = dragged.and_then(|id| self.tab_landing_rect(id, window));
|
|
crate::ui::reorder::suspend(&self.reorder, offer.is_some());
|
|
let (zone, rect) = offer?;
|
|
let area = self.pane_area.get()?;
|
|
self.tab_merge.set(Some((dragged?, zone)));
|
|
|
|
let accent = cx.theme().drag_border;
|
|
Some(
|
|
div()
|
|
.absolute()
|
|
.left(rect.origin.x - area.origin.x)
|
|
.top(rect.origin.y - area.origin.y)
|
|
.w(rect.size.width)
|
|
.h(rect.size.height)
|
|
.rounded(px(6.))
|
|
.border_2()
|
|
.border_color(accent)
|
|
.bg(accent.opacity(0.15))
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
/// Where the tab `id` would land in the tab on screen, if it can land there
|
|
/// at all — it cannot land in itself, and there is nothing to land in while
|
|
/// a pane is zoomed over the layout.
|
|
fn tab_landing_rect(
|
|
&self,
|
|
id: tty7_core::core::machine::TabId,
|
|
window: &Window,
|
|
) -> Option<(
|
|
crate::ui::pane_drag::DropZone<gpui::EntityId>,
|
|
Bounds<Pixels>,
|
|
)> {
|
|
use crate::ui::pane_drag;
|
|
|
|
if self.maximized.is_some() {
|
|
return None;
|
|
}
|
|
let area = self.pane_area.get()?;
|
|
let host = self.tabs.get(self.active)?;
|
|
if host.tree_id.get() == id {
|
|
return None;
|
|
}
|
|
let sub = &self.tabs.iter().find(|t| t.tree_id.get() == id)?.pane;
|
|
let leaves = host.pane.leaves();
|
|
let bounds = pane_drag::leaf_bounds(&host.pane, area);
|
|
let zone = pane_drag::tab_zone_at(area, &bounds, window.mouse_position())?;
|
|
// Named by pane rather than by position for the drop a frame later,
|
|
// the same way a pane drag's landing is.
|
|
let here = zone.map(|i| leaves.get(i).cloned())?;
|
|
let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?;
|
|
let rect = pane_drag::graft_landing(&host.pane, sub, here, area)?;
|
|
Some((pinned, rect))
|
|
}
|
|
|
|
/// Merges a dragged tab into the tab on screen, where the last painted
|
|
/// frame said it would go.
|
|
///
|
|
/// The panes come across as they were arranged, and the tab they came from
|
|
/// goes away with them — nothing is killed and nothing is respawned, so a
|
|
/// shell mid-command carries on through the move. What the source tab held
|
|
/// besides panes is the tab's own: its name goes, and its overlays come
|
|
/// along only where the tab receiving them has none of its own to lose.
|
|
fn merge_tab(
|
|
&mut self,
|
|
source: tty7_core::core::machine::TabId,
|
|
zone: crate::ui::pane_drag::DropZone<gpui::EntityId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.pane_hover.set(None);
|
|
let Some(from) = self.tabs.iter().position(|t| t.tree_id.get() == source) else {
|
|
return;
|
|
};
|
|
let Some(host) = self.tabs.get(self.active) else {
|
|
return;
|
|
};
|
|
if host.tree_id.get() == source {
|
|
return;
|
|
}
|
|
let leaves = host.pane.leaves();
|
|
let Some(zone) = zone.map(|id| leaves.iter().find(|l| l.entity_id() == id).cloned()) else {
|
|
return;
|
|
};
|
|
|
|
let mut moved = self.tabs.remove(from);
|
|
if from < self.active {
|
|
self.active -= 1;
|
|
}
|
|
// Lifted out rather than cloned: these are the live terminals, and a
|
|
// graft that finds nowhere to put them has to be able to hand them
|
|
// back intact.
|
|
let sub = std::mem::replace(&mut moved.pane, crate::ui::pane::Pane::Empty);
|
|
let first = sub.first_leaf();
|
|
let grafted = match self.tabs.get_mut(self.active) {
|
|
Some(host) => crate::ui::pane_drag::graft(&mut host.pane, sub, zone),
|
|
None => Err(sub),
|
|
};
|
|
if let Err(sub) = grafted {
|
|
moved.pane = sub;
|
|
self.tabs.insert(from, moved);
|
|
if from <= self.active {
|
|
self.active += 1;
|
|
}
|
|
return;
|
|
}
|
|
let host = &mut self.tabs[self.active];
|
|
if host.code.is_none() {
|
|
host.code = moved.code;
|
|
}
|
|
if host.diff_overlay.is_none() {
|
|
host.diff_overlay = moved.diff_overlay;
|
|
}
|
|
if self
|
|
.renaming
|
|
.as_ref()
|
|
.is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab))
|
|
{
|
|
self.renaming = None;
|
|
}
|
|
self.maximized = None;
|
|
if let Some(leaf) = first {
|
|
self.focus_leaf(&leaf, window, cx);
|
|
}
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// The caret between two tabs where a pane carried up to the strip — or
|
|
/// out to the sidebar — would become a tab of its own.
|
|
///
|
|
/// The reverse of grafting a tab in, and read the same way: offered only
|
|
/// when the drop would change something, which for a detach means the pane
|
|
/// has somewhere to leave. The last pane in a tab is already a tab, so
|
|
/// nothing lights up for it.
|
|
fn detach_caret(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
|
|
self.pane_detach.set(None);
|
|
let pane = crate::ui::pane_drag::lifted(&self.pane_drag)?;
|
|
let tab = self.tabs.get(self.active)?;
|
|
if tab.pane.leaves().len() < 2 {
|
|
return None;
|
|
}
|
|
if !tab.pane.leaves().iter().any(|l| l.entity_id() == pane) {
|
|
return None;
|
|
}
|
|
// The pane's own landing is read later in the frame and answers nothing
|
|
// while the pointer is up here, so the two are never both on offer;
|
|
// the drop takes this one first regardless.
|
|
let (at, caret) = self.detach_slot(window, cx)?;
|
|
self.pane_detach.set(Some((pane, at)));
|
|
|
|
let accent = cx.theme().drag_border;
|
|
Some(
|
|
div()
|
|
.absolute()
|
|
.left(caret.origin.x)
|
|
.top(caret.origin.y)
|
|
.w(caret.size.width)
|
|
.h(caret.size.height)
|
|
.rounded_full()
|
|
.bg(accent)
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
/// Which gap between tabs the pointer is in, as the tab a newcomer would
|
|
/// be inserted before and the caret marking it — on the strip, or on the
|
|
/// sidebar, whichever the pointer is over.
|
|
///
|
|
/// `viewport` and the pointer are both in the window's outer coordinates.
|
|
fn detach_slot(&self, window: &Window, cx: &App) -> Option<(usize, Bounds<Pixels>)> {
|
|
/// How far above its first row the sidebar's band starts, so the gap
|
|
/// over that row is inside it.
|
|
const BAND_REACH: f32 = 6.;
|
|
|
|
let pointer = window.mouse_position();
|
|
// The two surfaces never share a window: the sidebar *is* the tab bar
|
|
// when it is up, and the strip is what stands in for it when it is not.
|
|
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
|
|
&& !self.tabs.is_empty();
|
|
let viewport = window.viewport_size();
|
|
let pad = gpui_component::window_paddings(window);
|
|
// The band each surface claims, read off the tabs it drew rather than
|
|
// measured as an element of its own: the chips and the rows are the
|
|
// only part of either surface a drop has anything to say about, and a
|
|
// band derived from them cannot disagree with the gaps measured inside
|
|
// it. Stretched past the outermost tab so the empty space beyond —
|
|
// where the strip keeps its New Tab button, where the sidebar keeps
|
|
// nothing at all — still reads as "after everything".
|
|
let band = |axis: Axis, drawn: &[(usize, Bounds<Pixels>)]| {
|
|
let top = drawn.iter().map(|(_, b)| b.origin.y).reduce(Pixels::min)?;
|
|
let left = drawn.iter().map(|(_, b)| b.origin.x).reduce(Pixels::min)?;
|
|
let right = drawn
|
|
.iter()
|
|
.map(|(_, b)| b.origin.x + b.size.width)
|
|
.reduce(Pixels::max)?;
|
|
Some(match axis {
|
|
Axis::Horizontal => strip_band(viewport, pad),
|
|
Axis::Vertical => Bounds {
|
|
origin: point(left, top - px(BAND_REACH)),
|
|
size: size(
|
|
right - left,
|
|
(viewport.height - top + px(BAND_REACH)).max(px(0.)),
|
|
),
|
|
},
|
|
})
|
|
};
|
|
|
|
// Sorted by where they were drawn rather than by tab: the sidebar
|
|
// groups its rows, and a strip that has scrolled shows a window of
|
|
// chips. What the pointer is between is a matter of the screen.
|
|
let measured = |slots: &[Bounds<Pixels>]| -> Vec<(usize, Bounds<Pixels>)> {
|
|
slots
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, b)| b.size.width > px(0.) && b.size.height > px(0.))
|
|
.map(|(i, b)| (i, *b))
|
|
.collect()
|
|
};
|
|
let surfaces = [
|
|
(!vertical).then(|| (Axis::Horizontal, measured(&self.strip_slots.borrow()))),
|
|
self.sidebar_open(cx)
|
|
.then(|| (Axis::Vertical, measured(&self.sidebar_slots.borrow()))),
|
|
];
|
|
let (axis, mut drawn) = surfaces
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|(axis, drawn)| band(*axis, drawn).is_some_and(|b| b.contains(&pointer)))?;
|
|
let lead = |b: &Bounds<Pixels>| match axis {
|
|
Axis::Horizontal => b.origin.x,
|
|
Axis::Vertical => b.origin.y,
|
|
};
|
|
drawn.sort_by(|(_, a), (_, b)| lead(a).as_f32().total_cmp(&lead(b).as_f32()));
|
|
|
|
let row: Vec<Bounds<Pixels>> = drawn.iter().map(|(_, b)| *b).collect();
|
|
let (gap, caret) = crate::ui::pane_drag::insertion(&row, axis, pointer)?;
|
|
// The gap counts tabs on screen; what an insert needs is a place in the
|
|
// list. Past the last tab drawn is the end of the list, which is not
|
|
// the same thing as the last tab drawn plus one: a strip too narrow to
|
|
// show every chip has tabs on either side of the ones it drew.
|
|
let at = drawn.get(gap).map(|(i, _)| *i).unwrap_or(self.tabs.len());
|
|
Some((at, caret))
|
|
}
|
|
|
|
/// Takes a dragged pane out of its tab and gives it one of its own, where
|
|
/// the last painted frame's caret said.
|
|
///
|
|
/// Nothing is spawned and nothing is killed: the pane that leaves is the
|
|
/// same pane, still running whatever it was running.
|
|
fn detach_pane(
|
|
&mut self,
|
|
pane: gpui::EntityId,
|
|
at: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.pane_hover.set(None);
|
|
// Which pane the tab being left comes back to, settled while the one
|
|
// that is leaving is still in it to be ruled out.
|
|
self.remember_active_pane(window, cx);
|
|
let Some(tab) = self.tabs.get_mut(self.active) else {
|
|
return;
|
|
};
|
|
let Some(slot) = tab
|
|
.pane
|
|
.leaves()
|
|
.into_iter()
|
|
.find(|l| l.entity_id() == pane)
|
|
else {
|
|
return;
|
|
};
|
|
// Refused for the last pane in a tab, which is what makes a drop that
|
|
// would change nothing do nothing.
|
|
let Some(slot) = tab.pane.take_leaf(&slot) else {
|
|
return;
|
|
};
|
|
let cwd = slot
|
|
.terminal()
|
|
.and_then(|view| view.read(cx).spawnable_cwd());
|
|
let group = self.spawn_group(cwd.as_deref(), cx);
|
|
let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot));
|
|
if let Some(group) = group {
|
|
*fresh.sidebar_group.borrow_mut() = group;
|
|
}
|
|
let at = at.min(self.tabs.len());
|
|
self.tabs.insert(at, fresh);
|
|
self.maximized = None;
|
|
self.active = at;
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Puts a dragged pane down where the last painted frame said it would go.
|
|
///
|
|
/// Both ends of the drop are named by pane rather than by position, so a
|
|
/// pane that closed between the frame that offered the landing and this one
|
|
/// leaves the drop with nothing to land against, and it is refused.
|
|
fn drop_pane(
|
|
&mut self,
|
|
from: gpui::EntityId,
|
|
zone: crate::ui::pane_drag::DropZone<gpui::EntityId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.pane_hover.set(None);
|
|
let Some(tab) = self.tabs.get_mut(self.active) else {
|
|
return;
|
|
};
|
|
let leaves = tab.pane.leaves();
|
|
let here = |id| leaves.iter().find(|l| l.entity_id() == id).cloned();
|
|
let (Some(moved), Some(zone)) = (here(from), zone.map(here)) else {
|
|
return;
|
|
};
|
|
if !crate::ui::pane_drag::apply(&mut tab.pane, &moved, zone) {
|
|
return;
|
|
}
|
|
self.maximized = None;
|
|
self.focus_leaf(&moved, window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Activates the tab carrying `id`. A workspace this window just switched
|
|
/// to hydrates its tabs asynchronously, so when the tab is not here yet the
|
|
/// request is parked and claimed on the frame it arrives.
|
|
pub(crate) fn activate_tree_tab(
|
|
&mut self,
|
|
id: tty7_core::core::machine::TabId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match self.tabs.iter().position(|t| t.tree_id.get() == id) {
|
|
Some(index) => self.activate(index, window, cx),
|
|
None => self.pending_tab = Some(id),
|
|
}
|
|
}
|
|
|
|
fn claim_pending_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(want) = self.pending_tab else {
|
|
return;
|
|
};
|
|
let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == want) else {
|
|
return;
|
|
};
|
|
self.pending_tab = None;
|
|
self.activate(index, window, cx);
|
|
}
|
|
|
|
/// Declare to the pane registry which terminals are on screen this
|
|
/// frame: the active tab's, nobody else's. Stated per frame rather than
|
|
/// maintained at every tab operation, the way `scm_sync_watchers` is.
|
|
/// Entity ids only — reading the entities here would put them into the
|
|
/// window's tracked set, which is exactly what the registry routes
|
|
/// around.
|
|
fn declare_displayed_panes(&self, cx: &App) {
|
|
let active = self.active;
|
|
crate::terminal::view::declare_displayed(
|
|
cx,
|
|
self.tabs.iter().enumerate().flat_map(|(i, tab)| {
|
|
tab.pane
|
|
.leaves()
|
|
.into_iter()
|
|
.filter_map(move |slot| Some((slot.terminal()?.entity_id(), i == active)))
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Stamps whichever tab is active right now. Called once per frame rather
|
|
/// than from the ten places that assign `self.active` — it is idempotent,
|
|
/// so the stamp only advances on the first frame after a switch.
|
|
pub(crate) fn touch_active_tab(&self) {
|
|
let Some(tab) = self.tabs.get(self.active) else {
|
|
return;
|
|
};
|
|
let top = self.tab_use_seq.get();
|
|
if top != 0 && tab.last_used.get() == top {
|
|
return;
|
|
}
|
|
let next = top + 1;
|
|
self.tab_use_seq.set(next);
|
|
tab.last_used.set(next);
|
|
}
|
|
|
|
/// Tab indices most-recently-used first. The active tab always leads, even
|
|
/// before its own stamp lands; tabs never activated trail in strip order.
|
|
pub(crate) fn tabs_by_mru(&self) -> Vec<usize> {
|
|
let stamps: Vec<u64> = self.tabs.iter().map(|t| t.last_used.get()).collect();
|
|
mru_order(&stamps, self.active)
|
|
}
|
|
|
|
/// The neighbouring tab in the order the strip shows them, wrapping at
|
|
/// the ends — no switcher, no MRU (#867). "Shows" matters with the
|
|
/// sidebar grouping tabs: the next row is not always the next index.
|
|
fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
let order = self.visual_tab_order(cx);
|
|
if let Some(next) = step_in_order(&order, self.active, forward) {
|
|
self.activate(next, window, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
if index < self.tabs.len() && index != self.active {
|
|
self.remember_active_pane(window, cx);
|
|
// Zoom rides with its tab (#599): stash the outgoing tab's zoom
|
|
// and bring the incoming tab's back. The clears elsewhere (drag,
|
|
// split, close) still stand — those genuinely reshape what was
|
|
// zoomed; merely looking at another tab does not.
|
|
if let Some(outgoing) = self.tabs.get_mut(self.active) {
|
|
outgoing.zoomed = self.maximized.take();
|
|
}
|
|
self.active = index;
|
|
self.maximized = self.tabs[index].zoomed.take().filter(|leaf| {
|
|
// The zoomed pane may have exited while its tab was away —
|
|
// a stale entity must not come back as the zoom.
|
|
self.tabs[index]
|
|
.pane
|
|
.leaves()
|
|
.iter()
|
|
.any(|l| l.entity_id() == leaf.entity_id())
|
|
});
|
|
self.maybe_refresh_diff_overlay(cx);
|
|
self.sidebar_scroll.scroll_to_item(index);
|
|
if self.code_panel_visible() {
|
|
self.file_tree_refresh_roots(window, cx);
|
|
self.file_tree.focus_handle.focus(window, cx);
|
|
} else {
|
|
self.focus_active(window, cx);
|
|
}
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// Whether tab `index` has a pane zoomed over hidden siblings — what the
|
|
/// chrome marks so the state is readable without toggling it (#752).
|
|
///
|
|
/// Zoom rides with its tab (#599): the active tab's lives in
|
|
/// `self.maximized`, every other tab's is parked in `Tab::zoomed`. Either
|
|
/// can name a pane that exited while nobody was looking, which is why the
|
|
/// answer is asked of the layout rather than of the handle alone.
|
|
pub(crate) fn tab_is_zoomed(&self, index: usize) -> bool {
|
|
let Some(tab) = self.tabs.get(index) else {
|
|
return false;
|
|
};
|
|
let zoom = match index == self.active {
|
|
true => self.maximized.as_ref(),
|
|
false => tab.zoomed.as_ref(),
|
|
};
|
|
zoom.is_some_and(|zoom| {
|
|
tab.pane
|
|
.zoom_hides_siblings(|slot| slot.entity_id() == zoom.entity_id())
|
|
})
|
|
}
|
|
|
|
/// Zoom the pane being worked in over its siblings, or put the layout back.
|
|
///
|
|
/// Both directions name the pane outright rather than leaving it to
|
|
/// whatever holds focus at the instant the toggle lands (#869). Un-zooming
|
|
/// hands the tab the pane that was zoomed as its answer before focusing it:
|
|
/// that pane was the only one on screen to work in, and the tab's memory is
|
|
/// written by focus-in alone, which nothing promises has caught up. Zooming
|
|
/// with focus off the panes — a palette just closed, the tab strip — takes
|
|
/// the pane the tab remembers, the one switching back to it would focus,
|
|
/// and not whichever leaf the layout happens to list first.
|
|
fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(leaf) = self.maximized.take() {
|
|
remember_leaf_in(&mut self.tabs, leaf.entity_id());
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
let Some(tab) = self.tabs.get(self.active) else {
|
|
return;
|
|
};
|
|
if tab.pane.leaves().len() < 2 {
|
|
return;
|
|
}
|
|
let leaf = tab
|
|
.pane
|
|
.focused_leaf(window, cx)
|
|
.or_else(|| tab.focus_target())
|
|
.and_then(|slot| slot.terminal().cloned());
|
|
if let Some(leaf) = leaf {
|
|
let handle = leaf.read(cx).focus_handle.clone();
|
|
self.maximized = Some(leaf);
|
|
window.focus(&handle, cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.close_tab_inner(index, false, window, cx);
|
|
}
|
|
|
|
/// See [`Self::close_pane_inner`] for what `confirmed` carries.
|
|
fn close_tab_inner(
|
|
&mut self,
|
|
index: usize,
|
|
confirmed: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if index >= self.tabs.len() {
|
|
return;
|
|
}
|
|
if !confirmed && let Some(reason) = self.tab_close_reason(index, cx) {
|
|
let id = self.tabs[index].tree_id.get();
|
|
self.ask_before_closing(CloseTarget::Tab(id), reason, window, cx);
|
|
return;
|
|
}
|
|
self.maximized = None;
|
|
let worktree_cwd = self.tab_host_cwd(index, window, cx);
|
|
let snapshot = tab_to_session(&self.tabs[index], cx);
|
|
self.closed.push(snapshot);
|
|
if self.closed.len() > MAX_CLOSED_TABS {
|
|
self.closed.remove(0);
|
|
}
|
|
for leaf in self.tabs[index].pane.terminals() {
|
|
kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx);
|
|
}
|
|
self.tabs.remove(index);
|
|
// Only losing the renaming tab itself ends the rename — closing an
|
|
// unrelated tab must not throw the half-typed name away (#598).
|
|
if self
|
|
.renaming
|
|
.as_ref()
|
|
.is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab))
|
|
{
|
|
self.renaming = None;
|
|
}
|
|
if self.tabs.is_empty() {
|
|
self.active = 0;
|
|
} else if self.active >= self.tabs.len() {
|
|
self.active = self.tabs.len() - 1;
|
|
} else if index < self.active {
|
|
self.active -= 1;
|
|
}
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
self.offer_worktree_cleanup(worktree_cwd, cx);
|
|
}
|
|
|
|
fn offer_worktree_cleanup(
|
|
&mut self,
|
|
cwd: Option<(crate::ui::host_ops::SharedHost, std::path::PathBuf)>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some((host, cwd)) = cwd else { return };
|
|
let id = host.id();
|
|
let open_cwds: Vec<std::path::PathBuf> = self
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|tab| tab.pane.terminals())
|
|
.filter_map(|leaf| {
|
|
let view = leaf.read(cx);
|
|
(view.host_id() == id).then(|| view.host_cwd())?
|
|
})
|
|
.collect();
|
|
let remove_host = host.clone();
|
|
crate::ui::host_ops::HostOps::run(
|
|
host,
|
|
cx,
|
|
move |h| {
|
|
crate::core::worktree::managed(h, &cwd)
|
|
.filter(|wt| !crate::core::worktree::occupied(h, &wt.path, &open_cwds))
|
|
},
|
|
move |_this, found, cx| {
|
|
let Some(wt) = found else { return };
|
|
let path = wt.path.display().to_string();
|
|
let detail = if wt.dirty {
|
|
t_fmt(L10nKey::AppWorktreeRemoveDetailDirty, &[("path", &path)])
|
|
} else {
|
|
t_fmt(L10nKey::AppWorktreeRemoveDetailClean, &[("path", &path)])
|
|
};
|
|
let title = t_fmt(L10nKey::AppWorktreeRemoveTitle, &[("branch", &wt.branch)]);
|
|
let level = if wt.dirty {
|
|
PromptLevel::Warning
|
|
} else {
|
|
PromptLevel::Info
|
|
};
|
|
let remove_label = if wt.dirty {
|
|
t(L10nKey::AppWorktreeDiscardAndRemove)
|
|
} else {
|
|
t(L10nKey::AppWorktreeRemove)
|
|
};
|
|
cx.spawn(async move |this, cx| {
|
|
let Ok(answer) = this.update_in(cx, |_, window, cx| {
|
|
window.prompt(
|
|
level,
|
|
&title,
|
|
Some(&detail),
|
|
&crate::ui::confirm_answers(remove_label, t(L10nKey::AppWorktreeKeep)),
|
|
cx,
|
|
)
|
|
}) else {
|
|
return;
|
|
};
|
|
if !matches!(answer.await, Ok(0)) {
|
|
return;
|
|
}
|
|
let force = wt.dirty;
|
|
let branch = wt.branch.clone();
|
|
let _ = this.update_in(cx, |_, window, cx| {
|
|
crate::ui::host_ops::HostOps::run_in(
|
|
remove_host,
|
|
window,
|
|
cx,
|
|
move |h| crate::core::worktree::remove(h, &wt, force),
|
|
move |_this, result, window, cx| match result {
|
|
Ok(()) => window.push_notification(
|
|
t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]),
|
|
cx,
|
|
),
|
|
Err(e) => window.push_notification(
|
|
t_fmt(
|
|
L10nKey::AppWorktreeRemoveFailed,
|
|
&[("error", &e.to_string())],
|
|
),
|
|
cx,
|
|
),
|
|
},
|
|
);
|
|
});
|
|
})
|
|
.detach();
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) fn close_other_tabs(
|
|
&mut self,
|
|
index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if index >= self.tabs.len() {
|
|
return;
|
|
}
|
|
// A bulk close skips the tabs whose profile asked to be warned about,
|
|
// and closes the rest outright — one dialog per tab is not a question
|
|
// anyone can answer, and only the first would ever get asked. It
|
|
// deliberately does *not* skip merely busy tabs: on a working window
|
|
// that is most of them, and a menu item that quietly closes nothing is
|
|
// worse than one that closes what it says.
|
|
for i in (0..self.tabs.len()).rev() {
|
|
if i == index || self.tab_has_warn_ssh(i, cx) {
|
|
continue;
|
|
}
|
|
self.close_tab_inner(i, true, window, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn close_tabs_right_of(
|
|
&mut self,
|
|
index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Same bargain as `close_other_tabs`.
|
|
for i in ((index + 1)..self.tabs.len()).rev() {
|
|
if self.tab_has_warn_ssh(i, cx) {
|
|
continue;
|
|
}
|
|
self.close_tab_inner(i, true, window, cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) {
|
|
use crate::core::cli_agent::AgentStatus;
|
|
let Some(tab) = self.tabs.get(index) else {
|
|
return;
|
|
};
|
|
let refocus = (index == self.active).then(|| tab.focus_target()).flatten();
|
|
for leaf in tab.pane.terminals() {
|
|
let refocus_incoming =
|
|
refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id());
|
|
leaf.update(cx, |view, cx| {
|
|
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
|
|
view.mark_agent_result_unread(refocus_incoming, cx);
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// A tab's working directory, spelled for the clipboard.
|
|
///
|
|
/// A cwd on this machine is re-spelled with this OS's separators, because
|
|
/// what goes on the clipboard is meant to be pasted into a shell and a
|
|
/// mixed-separator path is not one Windows will take. A remote pane's cwd
|
|
/// keeps the spelling its own machine uses — it is already native over
|
|
/// there. Both "Copy working directory" entry points (the app-menu action
|
|
/// and the tab context menu) come through here so the two cannot drift.
|
|
pub(crate) fn tab_cwd_text(&self, index: usize, window: &Window, cx: &App) -> Option<String> {
|
|
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
|
|
let view = leaf.read(cx);
|
|
let cwd = view.cwd()?;
|
|
Some(match view.local_cwd().is_some() {
|
|
true => crate::ui::path_display::native_separators(&cwd)
|
|
.display()
|
|
.to_string(),
|
|
false => cwd.display().to_string(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context<Self>) {
|
|
if let Some(text) = self.tab_cwd_text(self.active, window, cx) {
|
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
|
}
|
|
}
|
|
|
|
pub(crate) fn tab_agent_session(
|
|
&self,
|
|
index: usize,
|
|
window: &Window,
|
|
cx: &App,
|
|
) -> Option<(Entity<TerminalView>, TabAgentSession)> {
|
|
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
|
|
let view = leaf.read(cx);
|
|
let agent = view.agent()?;
|
|
let session = TabAgentSession {
|
|
fork_label: agent.fork_label(),
|
|
session_id: view.agent_session().and_then(|s| s.session_id),
|
|
remote: view.remote_context().is_some(),
|
|
};
|
|
Some((leaf, session))
|
|
}
|
|
|
|
pub(crate) fn copy_agent_session_id(
|
|
&mut self,
|
|
index: usize,
|
|
window: &Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(id) = self
|
|
.tab_agent_session(index, window, cx)
|
|
.and_then(|(_, s)| s.session_id)
|
|
{
|
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string(id));
|
|
}
|
|
}
|
|
|
|
pub(crate) fn fork_agent_session(
|
|
&mut self,
|
|
index: usize,
|
|
source: Entity<TerminalView>,
|
|
placement: ForkPlacement,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(cmd) = self.agent_fork_command(&source, window, cx) else {
|
|
return;
|
|
};
|
|
|
|
if matches!(placement, ForkPlacement::Split { .. }) {
|
|
self.activate(index, window, cx);
|
|
}
|
|
|
|
let (cwd, shell) = {
|
|
let view = source.read(cx);
|
|
(view.local_cwd(), view.shell_spec())
|
|
};
|
|
let group = self.spawn_group(cwd.as_deref(), cx);
|
|
let new = match new_terminal(
|
|
self.window_workspace(cx),
|
|
Some(self.workspace),
|
|
self.font_size,
|
|
cwd,
|
|
None,
|
|
shell,
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("fork spawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let Some(terminal) = new.terminal() else {
|
|
log::error!("fork spawn produced a pane that is still connecting");
|
|
window.push_notification(t(L10nKey::AppForkStillConnecting), cx);
|
|
return;
|
|
};
|
|
terminal.read(cx).run_command_line(&cmd);
|
|
|
|
match placement {
|
|
ForkPlacement::NewTab => {
|
|
self.remember_active_pane(window, cx);
|
|
self.maximized = None;
|
|
let insert_at = self.new_tab_insert_at(cx);
|
|
let tab = Tab::new(Pane::leaf(new));
|
|
if let Some(group) = group {
|
|
*tab.sidebar_group.borrow_mut() = group;
|
|
}
|
|
self.tabs.insert(insert_at, tab);
|
|
self.active = insert_at;
|
|
self.focus_active(window, cx);
|
|
}
|
|
ForkPlacement::Split { axis, before } => {
|
|
let placed = self.tabs.get_mut(index).is_some_and(|tab| {
|
|
tab.pane
|
|
.split_leaf(source.entity_id(), axis, before, new.clone())
|
|
});
|
|
if !placed {
|
|
return;
|
|
}
|
|
self.maximized = None;
|
|
self.focus_leaf(&new, window, cx);
|
|
}
|
|
}
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn fork_active_pane_session(
|
|
&mut self,
|
|
placement: ForkPlacement,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(source) = self
|
|
.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx))
|
|
else {
|
|
return;
|
|
};
|
|
self.fork_agent_session(self.active, source, placement, window, cx);
|
|
}
|
|
|
|
pub(crate) fn fork_focused_pane_session(
|
|
&mut self,
|
|
axis: Axis,
|
|
before: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.fork_active_pane_session(ForkPlacement::Split { axis, before }, window, cx);
|
|
}
|
|
|
|
fn agent_fork_command(
|
|
&self,
|
|
source: &Entity<TerminalView>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<String> {
|
|
use crate::core::cli_agent::AgentStatus;
|
|
let view = source.read(cx);
|
|
let (agent, session, remote) = (view.agent(), view.agent_session(), view.remote_context());
|
|
let Some(agent) = agent else {
|
|
window.push_notification(t(L10nKey::AppPaneNoCodingAgent), cx);
|
|
return None;
|
|
};
|
|
let name = agent.display_name();
|
|
if agent.fork_label().is_none() {
|
|
window.push_notification(t_fmt(L10nKey::AppForkNoCommand, &[("name", &name)]), cx);
|
|
return None;
|
|
}
|
|
if remote.is_some() {
|
|
window.push_notification(t_fmt(L10nKey::AppForkLocalOnly, &[("name", &name)]), cx);
|
|
return None;
|
|
}
|
|
let session = session.unwrap_or_default();
|
|
let Some(id) = session.session_id.as_deref() else {
|
|
window.push_notification(t_fmt(L10nKey::AppForkNoSessionId, &[("name", &name)]), cx);
|
|
return None;
|
|
};
|
|
let Some(cmd) = agent.fork_command(id, session.launch_argv.as_deref()) else {
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppForkSessionIdNotToken, &[("name", &name)]),
|
|
cx,
|
|
);
|
|
return None;
|
|
};
|
|
if session.status == AgentStatus::Working {
|
|
window.push_notification(t_fmt(L10nKey::AppForkMidTurn, &[("name", &name)]), cx);
|
|
}
|
|
Some(cmd)
|
|
}
|
|
|
|
pub(crate) fn check_for_updates_now(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
crate::core::update::spawn_check_forced(cx);
|
|
self.open_settings_section(SettingsSection::About, window, cx);
|
|
}
|
|
|
|
fn tab_host_cwd(
|
|
&self,
|
|
index: usize,
|
|
window: &Window,
|
|
cx: &App,
|
|
) -> Option<(crate::ui::host_ops::SharedHost, std::path::PathBuf)> {
|
|
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
|
|
let view = leaf.read(cx);
|
|
Some((view.host(cx)?, view.host_cwd()?))
|
|
}
|
|
|
|
pub(crate) fn tab_is_in_repo(&self, index: usize, window: &Window, cx: &App) -> bool {
|
|
let Some(leaf) = self
|
|
.tabs
|
|
.get(index)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx))
|
|
else {
|
|
return false;
|
|
};
|
|
let view = leaf.read(cx);
|
|
let Some(cwd) = view.git_status_cwd() else {
|
|
return false;
|
|
};
|
|
cx.try_global::<crate::terminal::git_status::GitStatusCache>()
|
|
.and_then(|cache| cache.known_repo_for(view.host_id(), cwd))
|
|
.flatten()
|
|
.is_some()
|
|
}
|
|
|
|
pub(crate) fn new_worktree_tab(
|
|
&mut self,
|
|
index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some((host, cwd)) = self.tab_host_cwd(index, window, cx) else {
|
|
window.push_notification(t(L10nKey::AppTabNoWorkingDirectory), cx);
|
|
return;
|
|
};
|
|
let sheet_host = host.clone();
|
|
let probe_cwd = cwd.clone();
|
|
crate::ui::host_ops::HostOps::run_in(
|
|
host,
|
|
window,
|
|
cx,
|
|
move |h| crate::core::worktree::defaults(h, &probe_cwd),
|
|
move |this, result, window, cx| match result {
|
|
Ok(defaults) => this.open_worktree_prompt(sheet_host, cwd, defaults, window, cx),
|
|
Err(e) => window.push_notification(
|
|
t_fmt(L10nKey::AppNewWorktreeFailed, &[("error", &e.to_string())]),
|
|
cx,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) fn open_worktree_tab(
|
|
&mut self,
|
|
wt: crate::core::worktree::NewWorktree,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let view = match new_terminal(
|
|
self.window_workspace(cx),
|
|
Some(self.workspace),
|
|
self.font_size,
|
|
Some(wt.path),
|
|
None,
|
|
None,
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("worktree tab spawn failed: {e}");
|
|
window.push_notification(
|
|
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
self.remember_active_pane(window, cx);
|
|
self.maximized = None;
|
|
let insert_at = self.new_tab_insert_at(cx);
|
|
let mut tab = Tab::new(Pane::leaf(view));
|
|
tab.name = Some(wt.branch);
|
|
self.tabs.insert(insert_at, tab);
|
|
self.active = insert_at;
|
|
self.focus_active(window, cx);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context<Self>) {
|
|
if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) {
|
|
return;
|
|
}
|
|
// The rename box rides out a reorder: it tracks its tab by tree id,
|
|
// so the drift that once forced it closed here is gone (#598).
|
|
let was_active = self.active;
|
|
let mut slots: Vec<Option<Tab>> = std::mem::take(&mut self.tabs)
|
|
.into_iter()
|
|
.map(Some)
|
|
.collect();
|
|
self.tabs = order.iter().filter_map(|&i| slots[i].take()).collect();
|
|
self.active = order.iter().position(|&i| i == was_active).unwrap_or(0);
|
|
self.save_session(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Opens a rename box on the current name, selected and focused, so the
|
|
/// first thing typed replaces it.
|
|
pub(crate) fn rename_box(
|
|
current: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<InputState> {
|
|
let input = crate::ui::prefill::filled_box(current, window, cx);
|
|
input.update(cx, |state, cx| state.focus(window, cx));
|
|
input
|
|
}
|
|
|
|
pub(crate) fn start_rename(
|
|
&mut self,
|
|
index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.tabs.get(index).is_none() {
|
|
return;
|
|
}
|
|
let current = self.tab_label(&self.tabs[index], index, Some(&*window), cx);
|
|
let prefill = current.to_string();
|
|
let input = Self::rename_box(current, window, cx);
|
|
let subs = vec![cx.subscribe_in(
|
|
&input,
|
|
window,
|
|
|this, _input, ev: &InputEvent, window, cx| match ev {
|
|
InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
|
|
_ => {}
|
|
},
|
|
)];
|
|
self.renaming = Some(Renaming {
|
|
tab: self.tabs[index].tree_id.get(),
|
|
input,
|
|
prefill,
|
|
_subs: subs,
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let current =
|
|
crate::ui::machine_mirror::display_name_for(cx, self.workspace).unwrap_or_default();
|
|
let input = Self::rename_box(current, window, cx);
|
|
let subs = vec![cx.subscribe_in(
|
|
&input,
|
|
window,
|
|
|this, _input, ev: &InputEvent, window, cx| match ev {
|
|
InputEvent::PressEnter { .. } | InputEvent::Blur => {
|
|
this.commit_workspace_rename(window, cx)
|
|
}
|
|
_ => {}
|
|
},
|
|
)];
|
|
self.workspace_rename = Some(WorkspaceRename { input, _subs: subs });
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn commit_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(rename) = self.workspace_rename.take() else {
|
|
return;
|
|
};
|
|
let value = rename.input.read(cx).value().trim().to_string();
|
|
let id = self.workspace;
|
|
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
|
|
crate::ui::windows::refresh_menu(cx);
|
|
self.sync_window_title(window, cx);
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(renaming) = self.renaming.take() else {
|
|
return;
|
|
};
|
|
let value = renaming.input.read(cx).value();
|
|
match rename_outcome(&value, &renaming.prefill) {
|
|
Rename::Unchanged => {
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
outcome => {
|
|
if let Some(tab) = self
|
|
.tabs
|
|
.iter_mut()
|
|
.find(|t| t.tree_id.get() == renaming.tab)
|
|
{
|
|
tab.name = match outcome {
|
|
Rename::Named(name) => Some(name),
|
|
_ => None,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
self.save_session(cx);
|
|
crate::ui::windows::refresh_menu(cx);
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn palette_commands(&self, window: &Window, cx: &App) -> Vec<Command> {
|
|
let mut commands = Command::base_commands(
|
|
cx,
|
|
ChromeState {
|
|
rail_collapsed: self.sidebar_collapsed,
|
|
right_panel_visible: self.right_panel_visible,
|
|
document_filled: self.document_layout(cx)
|
|
== crate::core::config::DocumentLayout::Fill,
|
|
remote_server: WorkspaceStore::remote_ref(cx, self.workspace)
|
|
.filter(|remote| remote.target.hosts_our_server())
|
|
.map(|remote| crate::ui::remote_connect::route_label(cx, &remote)),
|
|
},
|
|
);
|
|
|
|
// Offered only where it would do something. A connection opened from a
|
|
// saved host has nothing to save, and a pane that is not an SSH one has
|
|
// no connection at all — either would be a row that quietly did nothing
|
|
// (#549).
|
|
if self.unsaved_ssh_session(window, cx).is_some() {
|
|
commands.push(
|
|
Command::localized(
|
|
L10nKey::CmdSshSaveConnection,
|
|
CommandKind::SaveSshSessionAsHost,
|
|
)
|
|
.with_subtitle(t(L10nKey::CmdSshSaveConnectionSubtitle))
|
|
.in_group(CommandGroup::Ssh),
|
|
);
|
|
}
|
|
|
|
for p in crate::ui::ssh_connect::ssh_profiles_by_frecency(cx) {
|
|
let subtitle = crate::core::ssh_profile::to_connect_string(&p);
|
|
let title = if p.name.is_empty() {
|
|
subtitle.clone()
|
|
} else {
|
|
p.name.clone()
|
|
};
|
|
commands.push(
|
|
Command::new(
|
|
t_fmt(L10nKey::AppCmdSshProfileTitle, &[("title", &title)]),
|
|
CommandKind::ConnectSavedProfile(p.id),
|
|
)
|
|
.with_subtitle(subtitle)
|
|
.in_group(CommandGroup::Ssh),
|
|
);
|
|
}
|
|
|
|
for (i, tab) in self.tabs.iter().enumerate() {
|
|
if i == self.active {
|
|
continue;
|
|
}
|
|
let label = self.tab_label(tab, i, None, cx);
|
|
commands.push(
|
|
Command::new(
|
|
t_fmt(L10nKey::AppCmdSwitchToTab, &[("label", &label)]),
|
|
CommandKind::ActivateTab(i),
|
|
)
|
|
.in_group(CommandGroup::TabsPanes),
|
|
);
|
|
}
|
|
commands
|
|
}
|
|
|
|
fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.palette.is_some() {
|
|
self.close_palette(window, cx);
|
|
return;
|
|
}
|
|
self.open_palette("", window, cx);
|
|
}
|
|
|
|
/// Open the palette with `query` already in its search field.
|
|
///
|
|
/// Unlike [`Self::toggle_palette`] this always opens. A row that names
|
|
/// what it will show cannot also be the thing that dismisses it, and the
|
|
/// only ways in here are rows like that.
|
|
pub(crate) fn open_palette(
|
|
&mut self,
|
|
query: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let commands = self.palette_commands(window, cx);
|
|
let view = cx.new(|cx| PaletteView::seeded(commands, query, window, cx));
|
|
self.palette_sub = Some(cx.subscribe_in(&view, window, Self::on_palette_event));
|
|
self.palette = Some(view);
|
|
cx.notify();
|
|
}
|
|
|
|
fn on_palette_event(
|
|
&mut self,
|
|
_view: &Entity<PaletteView>,
|
|
ev: &PaletteEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match ev {
|
|
PaletteEvent::Confirm(kind) => {
|
|
let kind = kind.clone();
|
|
// The picker is already showing this theme; keep it through the
|
|
// close instead of reverting and re-applying it.
|
|
if matches!(kind, CommandKind::SetTheme(_)) {
|
|
self.theme_preview_restore = None;
|
|
}
|
|
self.close_palette(window, cx);
|
|
self.run_command(kind, window, cx);
|
|
}
|
|
PaletteEvent::Dismiss => self.close_palette(window, cx),
|
|
PaletteEvent::PreviewTheme(i) => {
|
|
if let Some(id) = crate::ui::presets::all(cx).get(*i).map(|t| t.id.clone()) {
|
|
self.preview_preset(&id, window, cx);
|
|
}
|
|
}
|
|
PaletteEvent::CancelThemePreview => self.cancel_preset_preview(window, cx),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.palette = None;
|
|
self.palette_sub = None;
|
|
// A previewed theme was never persisted: closing the palette any way
|
|
// other than confirming the pick puts the old one back.
|
|
self.cancel_preset_preview(window, cx);
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn focused_leaf(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> {
|
|
self.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx))
|
|
}
|
|
|
|
fn bump_command_frecency(&mut self, kind: &CommandKind, cx: &mut Context<Self>) {
|
|
let Some(id) = kind.id() else { return };
|
|
self.update_config(cx, |cfg| {
|
|
let entry = cfg.command_frecency.entry(id.to_string()).or_default();
|
|
entry.count = entry.count.saturating_add(1);
|
|
entry.last_used = crate::core::config::unix_now();
|
|
});
|
|
}
|
|
|
|
fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context<Self>) {
|
|
use CommandKind::*;
|
|
self.bump_command_frecency(&kind, cx);
|
|
match kind {
|
|
NewTab => self.new_tab(window, cx),
|
|
NewWorkspace => self.open_workspace_form(window, cx),
|
|
NewWindow => self.new_window(cx),
|
|
OpenWorkspacePicker => self.open_switcher(window, cx),
|
|
StopWorkspace => self.stop_workspace(self.workspace, window, cx),
|
|
DeleteWorkspace => self.delete_workspace(self.workspace, window, cx),
|
|
CloseWindow => self.close_window(window, cx),
|
|
SplitRight => self.split(Axis::Horizontal, window, cx),
|
|
SplitDown => self.split(Axis::Vertical, window, cx),
|
|
ClosePane => self.close_pane(window, cx),
|
|
NextPane => self.cycle_pane(true, window, cx),
|
|
PrevPane => self.cycle_pane(false, window, cx),
|
|
FocusPaneLeft => self.focus_pane_dir(Dir::Left, window, cx),
|
|
FocusPaneRight => self.focus_pane_dir(Dir::Right, window, cx),
|
|
FocusPaneUp => self.focus_pane_dir(Dir::Up, window, cx),
|
|
FocusPaneDown => self.focus_pane_dir(Dir::Down, window, cx),
|
|
ResizePaneLeft => self.resize_pane(Dir::Left, window, cx),
|
|
ResizePaneRight => self.resize_pane(Dir::Right, window, cx),
|
|
ResizePaneUp => self.resize_pane(Dir::Up, window, cx),
|
|
ResizePaneDown => self.resize_pane(Dir::Down, window, cx),
|
|
SwapPaneNext => self.swap_pane(true, window, cx),
|
|
SwapPanePrev => self.swap_pane(false, window, cx),
|
|
SelectNextTab => self.cycle_tab(true, window, cx),
|
|
SelectPrevTab => self.cycle_tab(false, window, cx),
|
|
ToggleMaximizePane => self.toggle_maximize(window, cx),
|
|
ToggleFullscreen => self.toggle_fullscreen(window, cx),
|
|
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
|
|
ToggleLeftPanel => self.toggle_left_panel(cx),
|
|
ToggleRightPanel => self.toggle_right_panel(cx),
|
|
ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx),
|
|
ResetFontSize => self.reset_font_size(cx),
|
|
FindInTerminal => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.open_search(window, cx));
|
|
}
|
|
}
|
|
FindNext => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.find_step(true, cx));
|
|
}
|
|
}
|
|
FindPrevious => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.find_step(false, cx));
|
|
}
|
|
}
|
|
ClearTerminal => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.clear_scrollback(cx));
|
|
}
|
|
}
|
|
CopyText => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| {
|
|
view.copy_contextual(false, cx);
|
|
});
|
|
}
|
|
}
|
|
CutText => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| {
|
|
view.cut_contextual(cx);
|
|
});
|
|
}
|
|
}
|
|
PasteText => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.paste_from_clipboard(cx));
|
|
}
|
|
}
|
|
SelectAllText => {
|
|
if let Some(leaf) = self.focused_leaf(window, cx) {
|
|
leaf.update(cx, |view, cx| view.select_all_contextual(cx));
|
|
}
|
|
}
|
|
ReopenClosedTab => self.reopen_closed_tab(window, cx),
|
|
RenameTab => self.start_rename(self.active, window, cx),
|
|
NewWorktreeTab => self.new_worktree_tab(self.active, window, cx),
|
|
CloseOtherTabs => self.close_other_tabs(self.active, window, cx),
|
|
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
|
|
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
|
|
MarkTabUnread => self.mark_tab_unread(self.active, cx),
|
|
ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx),
|
|
CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx),
|
|
RenameWorkspace => self.start_workspace_rename(window, cx),
|
|
OpenSettings => self.toggle_settings(window, cx),
|
|
ShowKeyboardShortcuts => {
|
|
self.open_settings_section(SettingsSection::Keybindings, window, cx)
|
|
}
|
|
About => self.open_settings_section(SettingsSection::About, window, cx),
|
|
CheckForUpdates => self.check_for_updates_now(window, cx),
|
|
OpenDocumentation => cx.open_url(DOCS_URL),
|
|
OpenDiscord => cx.open_url(DISCORD_URL),
|
|
ReportIssue => cx.open_url(ISSUES_URL),
|
|
// Same exit as the tray's: confirm, then stop the server with the
|
|
// app. A bare quit would leave the daemon orphaned behind a dead
|
|
// tray icon.
|
|
Quit => self.quit_stop_sessions(window, cx),
|
|
RestartDaemon => self.restart_window_daemon(window, cx),
|
|
UpdateLocalServer => self.update_local_server(window, cx),
|
|
UpdateRemoteServer => self.update_window_remote_server(window, cx),
|
|
ToggleSftp => self.toggle_sftp(window, cx),
|
|
ShowSshForwards => self.show_ssh_forwards(window, cx),
|
|
ToggleCodePanel => self.toggle_code_panel(window, cx),
|
|
ToggleDocumentFill => self.toggle_document_fill(cx),
|
|
DocumentWidthThird => {
|
|
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
|
|
}
|
|
DocumentWidthHalf => {
|
|
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
|
|
}
|
|
DocumentWidthTwoThirds => {
|
|
self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
|
|
}
|
|
RestartSshSession => self.restart_ssh_session(window, cx),
|
|
SetTheme(i) => {
|
|
if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) {
|
|
self.set_preset(&id, window, cx);
|
|
}
|
|
}
|
|
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
|
|
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
|
|
EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx),
|
|
QuickConnect(target) => {
|
|
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
|
|
self.quick_connect(qc, window, cx);
|
|
}
|
|
}
|
|
SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx),
|
|
SaveSshSessionAsHost => self.save_ssh_session_as_host(window, cx),
|
|
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
|
|
SendSelectionToAgent => self.send_selection_to_agent(window, cx),
|
|
SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
|
|
ScmCommit => self.run_scm_action(ScmIntent::Commit, window, cx),
|
|
ScmStageAll => self.run_scm_action(ScmIntent::StageAll, window, cx),
|
|
ScmUnstageAll => self.run_scm_action(ScmIntent::UnstageAll, window, cx),
|
|
ScmDiscardAll => self.run_scm_action(ScmIntent::DiscardAll, window, cx),
|
|
ScmPush => self.run_scm_action(ScmIntent::Push, window, cx),
|
|
ScmPull => self.run_scm_action(ScmIntent::Pull, window, cx),
|
|
ScmFetch => self.run_scm_action(ScmIntent::Fetch, window, cx),
|
|
ScmSync => self.run_scm_action(ScmIntent::Sync, window, cx),
|
|
ScmCreateBranch => self.run_scm_action(ScmIntent::CreateBranch, window, cx),
|
|
OpenBranchPicker => self.run_scm_action(ScmIntent::CheckoutBranch, window, cx),
|
|
ToggleDiffViewMode => self.toggle_diff_view_mode(cx),
|
|
OpenThemePicker | OpenSshConnectInput => {}
|
|
ActivateTab(i) => self.activate(i, window, cx),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
|
|
let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some();
|
|
if let Some(tab) = self.tabs.get(self.active)
|
|
&& let Some(leaf) = tab.pane.terminals().into_iter().find(runs_agent)
|
|
{
|
|
return Some(leaf);
|
|
}
|
|
self.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, _)| *i != self.active)
|
|
.flat_map(|(_, t)| t.pane.terminals())
|
|
.find(runs_agent)
|
|
}
|
|
|
|
fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(target) = self.agent_target_leaf(cx) else {
|
|
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
|
|
return;
|
|
};
|
|
target.read(cx).send_agent_prompt(prompt);
|
|
if let Some(i) = self
|
|
.tabs
|
|
.iter()
|
|
.position(|t| t.pane.terminals().contains(&target))
|
|
{
|
|
self.activate(i, window, cx);
|
|
}
|
|
}
|
|
|
|
fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let source = self
|
|
.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx));
|
|
let (selection, cwd) = match &source {
|
|
Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()),
|
|
None => (None, None),
|
|
};
|
|
let Some(selection) = selection else {
|
|
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNothingSelected));
|
|
return;
|
|
};
|
|
let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
|
|
if let Some(prompt) =
|
|
crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref())
|
|
{
|
|
self.deliver_agent_prompt(&prompt, window, cx);
|
|
}
|
|
}
|
|
|
|
fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let pane = self
|
|
.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx));
|
|
let target = pane.and_then(|view| {
|
|
let view = view.read(cx);
|
|
Some((view.host(cx)?, view.host_cwd()?))
|
|
});
|
|
let Some((host, cwd)) = target else {
|
|
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppPaneNoKnownDirectory));
|
|
return;
|
|
};
|
|
crate::ui::host_ops::HostOps::run_in(
|
|
host,
|
|
window,
|
|
cx,
|
|
move |h| {
|
|
let run = |args: &[&str]| {
|
|
h.git(&cwd, args)
|
|
.ok()
|
|
.filter(|o| o.success())
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
|
.unwrap_or_default()
|
|
};
|
|
let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
|
|
(diff, cwd.to_string_lossy().into_owned())
|
|
},
|
|
move |this, (diff, cwd_s), window, cx| {
|
|
match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
|
|
Some(prompt) => this.deliver_agent_prompt(&prompt, window, cx),
|
|
None => crate::terminal::notify_desktop(
|
|
Some("tty7"),
|
|
&t_fmt(L10nKey::AppNoUncommittedChanges, &[("cwd", &cwd_s)]),
|
|
),
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) fn reset_settings_value(
|
|
&mut self,
|
|
title: L10nKey,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let defaults = Config::default();
|
|
match title {
|
|
L10nKey::SettingsDimInactivePanes => {
|
|
self.set_dim_inactive_panes(defaults.dim_inactive_panes, cx)
|
|
}
|
|
L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx),
|
|
L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx),
|
|
L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx),
|
|
L10nKey::SettingsNewTabPosition => {
|
|
self.set_new_tab_position(defaults.new_tab_position, cx)
|
|
}
|
|
L10nKey::SettingsTabBarPosition => {
|
|
self.set_tab_bar_position(defaults.tab_bar_position, cx)
|
|
}
|
|
L10nKey::SettingsSidebarGrouping => {
|
|
self.set_sidebar_grouping(defaults.sidebar_grouping, cx)
|
|
}
|
|
L10nKey::SettingsDiffPreviewFromCounts => {
|
|
self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx)
|
|
}
|
|
L10nKey::SettingsNotifyOnCommandFinish => {
|
|
self.set_notify_mode(defaults.notify_on_command_finish, cx)
|
|
}
|
|
L10nKey::SettingsNotifyThreshold => {
|
|
self.set_notify_threshold(defaults.notify_threshold_secs, cx)
|
|
}
|
|
L10nKey::SettingsTerminalBell => self.set_bell_mode(defaults.bell, cx),
|
|
L10nKey::SettingsRestoreLastLayout => {
|
|
self.set_restore_session(defaults.restore_session, cx)
|
|
}
|
|
L10nKey::SettingsPerPaneHistory => {
|
|
self.set_per_pane_history(defaults.per_pane_history, cx)
|
|
}
|
|
L10nKey::SettingsShowTrayIcon => self.set_show_tray_icon(defaults.show_tray_icon, cx),
|
|
L10nKey::SettingsOptionAsMeta => {
|
|
self.set_macos_option_as_alt(defaults.macos_option_as_alt, cx)
|
|
}
|
|
L10nKey::SettingsHideMouseWhileTyping => {
|
|
self.set_mouse_hide_while_typing(defaults.mouse_hide_while_typing, cx)
|
|
}
|
|
L10nKey::SettingsFocusFollowsMouse => {
|
|
self.set_focus_follows_mouse(defaults.focus_follows_mouse, cx)
|
|
}
|
|
L10nKey::SettingsReportMouseToApps => {
|
|
self.set_mouse_reporting(defaults.mouse_reporting, cx)
|
|
}
|
|
L10nKey::SettingsScrollSpeed => {
|
|
self.set_mouse_scroll_multiplier(defaults.mouse_scroll_multiplier, cx)
|
|
}
|
|
L10nKey::SettingsSmoothScroll => self.set_smooth_scroll(defaults.smooth_scroll, cx),
|
|
L10nKey::SettingsMouseZoom => {
|
|
self.set_mouse_zoom_modifier(defaults.mouse_zoom_modifier, cx)
|
|
}
|
|
L10nKey::SettingsTrimTrailingSpaces => {
|
|
self.set_clipboard_trim(defaults.clipboard_trim_trailing_spaces, cx)
|
|
}
|
|
L10nKey::SettingsCopyOnSelect => self.set_copy_on_select(defaults.copy_on_select, cx),
|
|
L10nKey::SettingsSmartSelection => self.set_smart_select(defaults.smart_select, cx),
|
|
L10nKey::SettingsPromptEditor => self.set_prompt_editor(defaults.prompt_editor, cx),
|
|
L10nKey::SettingsTabCompletion => self.set_tab_completion(defaults.tab_completion, cx),
|
|
L10nKey::SettingsHistorySearch => self.set_history_search(defaults.history_search, cx),
|
|
L10nKey::SettingsStartupWindow => self.set_startup_mode(defaults.startup_mode, cx),
|
|
L10nKey::SettingsRememberWindowSize => {
|
|
self.set_remember_window_size(defaults.remember_window_size, cx)
|
|
}
|
|
L10nKey::SettingsCheckUpdatesOnLaunch => {
|
|
self.set_check_for_updates(defaults.check_for_updates, cx)
|
|
}
|
|
L10nKey::SettingsAutoDownload => {
|
|
self.set_auto_download_updates(defaults.auto_download_updates, cx)
|
|
}
|
|
L10nKey::SettingsUpdateChannel => self.set_update_channel(defaults.update_channel, cx),
|
|
L10nKey::DetectUrls => self.set_link_url(defaults.link_url, cx),
|
|
L10nKey::ForwardSshLoopbackLinks => {
|
|
self.set_ssh_loopback_forward(defaults.ssh_loopback_forward, cx)
|
|
}
|
|
L10nKey::SettingsVerifyHostKeys => {
|
|
self.set_verify_host_keys(defaults.verify_host_keys, cx)
|
|
}
|
|
L10nKey::WarnBeforeClosing => {
|
|
self.set_ssh_warn_on_close(defaults.ssh_warn_on_close, cx)
|
|
}
|
|
L10nKey::SettingsLanguage => self.set_gui_language(
|
|
Self::normalize_gui_language(&defaults.gui_language),
|
|
window,
|
|
cx,
|
|
),
|
|
L10nKey::SettingsFontSize => self.reset_font_size(cx),
|
|
L10nKey::SettingsUiFontSize => self.reset_ui_font_size(cx),
|
|
L10nKey::SettingsLineHeight => self.reset_line_height(cx),
|
|
L10nKey::SettingsFontFamily => {
|
|
self.commit_font_family(defaults.font_family.clone(), cx)
|
|
}
|
|
L10nKey::SettingsBoldFont => self.commit_font_family_emphasis(
|
|
true,
|
|
crate::ui::settings::font_default_label().to_string(),
|
|
cx,
|
|
),
|
|
L10nKey::SettingsItalicFont => self.commit_font_family_emphasis(
|
|
false,
|
|
crate::ui::settings::font_default_label().to_string(),
|
|
cx,
|
|
),
|
|
L10nKey::SettingsUiFontFamily => self.commit_ui_font_family(
|
|
crate::ui::settings::ui_font_default_label().to_string(),
|
|
window,
|
|
cx,
|
|
),
|
|
L10nKey::SettingsSyncWithSystem => {
|
|
self.set_theme_follow_system(defaults.theme_follow_system, window, cx)
|
|
}
|
|
L10nKey::SettingsLegiblePalette => {
|
|
self.set_theme_legible_palette(defaults.theme_legible_palette, window, cx)
|
|
}
|
|
L10nKey::SettingsOpacity => {
|
|
self.update_config(cx, |cfg| cfg.window_opacity = None);
|
|
apply_theme(Some(window), cx);
|
|
}
|
|
L10nKey::SettingsBlur => {
|
|
self.update_config(cx, |cfg| cfg.window_blur = None);
|
|
apply_theme(Some(window), cx);
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
L10nKey::SettingsBackdrop => {
|
|
self.set_window_backdrop(defaults.window_backdrop, window, cx)
|
|
}
|
|
L10nKey::SettingsFontLigatures => self.set_font_ligatures(
|
|
defaults
|
|
.font_features
|
|
.as_ref()
|
|
.is_some_and(|f| f.is_calt_enabled() == Some(true)),
|
|
cx,
|
|
),
|
|
L10nKey::OpenFilesWith => {
|
|
self.update_config(cx, |cfg| cfg.link_file_open = defaults.link_file_open);
|
|
}
|
|
L10nKey::SettingsProgram => {
|
|
self.update_config(cx, |cfg| cfg.shell = defaults.shell.clone())
|
|
}
|
|
L10nKey::SettingsArguments => self.update_config(cx, |cfg| {
|
|
if let Some(shell) = &mut cfg.shell {
|
|
shell.args.clear();
|
|
}
|
|
}),
|
|
L10nKey::SettingsStartIn => self.update_config(cx, |cfg| {
|
|
cfg.working_directory.strategy = defaults.working_directory.strategy
|
|
}),
|
|
L10nKey::SettingsCustomPath => self.update_config(cx, |cfg| {
|
|
cfg.working_directory.path = defaults.working_directory.path.clone()
|
|
}),
|
|
L10nKey::SettingsAppHttpProxy => {
|
|
self.update_config(cx, |cfg| cfg.http_proxy = defaults.http_proxy.clone())
|
|
}
|
|
L10nKey::SettingsOpenFilesCommand => self.update_config(cx, |cfg| {
|
|
cfg.link_file_command = defaults.link_file_command.clone()
|
|
}),
|
|
_ => return,
|
|
}
|
|
self.refresh_settings_controls(title, window, cx);
|
|
}
|
|
|
|
fn refresh_settings_controls(
|
|
&mut self,
|
|
title: L10nKey,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut subs = Vec::new();
|
|
match title {
|
|
L10nKey::SettingsFontFamily
|
|
| L10nKey::SettingsBoldFont
|
|
| L10nKey::SettingsItalicFont
|
|
| L10nKey::SettingsUiFontFamily => {
|
|
let (font, bold, italic, ui) = self.build_font_selects(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.font_select = font;
|
|
s.font_bold_select = bold;
|
|
s.font_italic_select = italic;
|
|
s.ui_font_select = ui;
|
|
}
|
|
}
|
|
L10nKey::SettingsLanguage => {
|
|
let value = self.build_language_select(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.language_select = value;
|
|
}
|
|
}
|
|
L10nKey::SettingsProgram | L10nKey::SettingsArguments => {
|
|
let (program, args, _) = self.build_shell_inputs(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.shell_program_input = program;
|
|
s.shell_args_input = args;
|
|
}
|
|
}
|
|
L10nKey::SettingsCustomPath => {
|
|
let value = cx.global::<Config>().working_directory.path.clone();
|
|
if let Some(s) = self.active_settings() {
|
|
s.wd_path_input
|
|
.clone()
|
|
.update(cx, |s, cx| s.set_value(value, window, cx));
|
|
}
|
|
}
|
|
L10nKey::SettingsOpenFilesCommand => {
|
|
let value = self.build_link_file_command_input(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.link_file_command_input = value;
|
|
}
|
|
}
|
|
L10nKey::SettingsAppHttpProxy => {
|
|
let value = self.build_http_proxy_input(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.http_proxy_input = value;
|
|
}
|
|
}
|
|
L10nKey::SettingsScrollSpeed => {
|
|
let value = self.build_scroll_slider(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.scroll_slider = value;
|
|
}
|
|
}
|
|
L10nKey::SettingsOpacity | L10nKey::SettingsBlur | L10nKey::SettingsBackdrop => {
|
|
let value = self.build_window_opacity_slider(&mut subs, window, cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.window_opacity_slider = value;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s._subs.extend(subs);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.settings.is_some() {
|
|
self.close_settings_checked(window, cx);
|
|
return;
|
|
}
|
|
self.remember_active_pane(window, cx);
|
|
let focus_handle = cx.focus_handle();
|
|
let mut subs = Vec::new();
|
|
let (font_select, font_bold_select, font_italic_select, ui_font_select) =
|
|
self.build_font_selects(&mut subs, window, cx);
|
|
let language_select = self.build_language_select(&mut subs, window, cx);
|
|
#[cfg(target_os = "windows")]
|
|
let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx);
|
|
let (shell_program_input, shell_args_input, wd_path_input) =
|
|
self.build_shell_inputs(&mut subs, window, cx);
|
|
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
|
|
let http_proxy_input = self.build_http_proxy_input(&mut subs, window, cx);
|
|
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
|
|
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
|
|
let theme_search = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchThemes))
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
}),
|
|
);
|
|
let settings_search = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchSettings))
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&settings_search, window, |this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
this.autoselect_settings_search(cx);
|
|
if let Some(s) = this.active_settings_mut() {
|
|
s.reveal_first_hit.set(true);
|
|
}
|
|
cx.notify();
|
|
}
|
|
}),
|
|
);
|
|
|
|
let shortcut_search = cx
|
|
.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::SettingsNavKeybindings)));
|
|
subs.push(
|
|
cx.subscribe_in(&shortcut_search, window, |_, _, ev, _, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
}),
|
|
);
|
|
|
|
let ssh_filter = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::FilterHosts))
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&ssh_filter, window, |_this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
}),
|
|
);
|
|
|
|
let ssh_quick_connect = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderSshQuickConnect))
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&ssh_quick_connect, window, |_this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::Change) {
|
|
cx.notify();
|
|
}
|
|
}),
|
|
);
|
|
|
|
let content_scroll = gpui::ScrollHandle::new();
|
|
content_scroll.set_offset(self.last_settings_location.1);
|
|
let search_anchor = gpui::ScrollAnchor::for_handle(content_scroll.clone());
|
|
|
|
self.settings = Some(SettingsState {
|
|
focus_handle: focus_handle.clone(),
|
|
section: self.last_settings_location.0,
|
|
search: settings_search,
|
|
shortcut_search,
|
|
modified_only: false,
|
|
search_active: false,
|
|
search_return_offset: self.last_settings_location.1,
|
|
search_selection: 0,
|
|
search_rows: std::cell::RefCell::new(None),
|
|
focused_setting: None,
|
|
content_scroll,
|
|
ssh_master_scroll: gpui::ScrollHandle::new(),
|
|
ssh_detail_scroll: gpui::ScrollHandle::new(),
|
|
theme_list_scroll: gpui::ScrollHandle::new(),
|
|
search_anchor,
|
|
reveal_first_hit: Cell::new(false),
|
|
font_select,
|
|
font_bold_select,
|
|
font_italic_select,
|
|
ui_font_select,
|
|
language_select,
|
|
#[cfg(target_os = "windows")]
|
|
window_backdrop_select,
|
|
shell_program_input,
|
|
shell_args_input,
|
|
wd_path_input,
|
|
link_file_command_input,
|
|
http_proxy_input,
|
|
scroll_slider,
|
|
window_opacity_slider,
|
|
theme_editor: None,
|
|
theme_draft: None,
|
|
theme_draft_error: None,
|
|
save_error: None,
|
|
saved_config: cx.global::<Config>().clone(),
|
|
theme_panel_open: false,
|
|
theme_panel_slot: crate::ui::settings::ThemeSlot::Manual,
|
|
theme_search,
|
|
recording: None,
|
|
rebinding_note: None,
|
|
ssh_form: None,
|
|
ssh_detail: crate::ui::settings::SshDetail::None,
|
|
ssh_filter,
|
|
ssh_collapsed_groups: std::collections::HashSet::new(),
|
|
ssh_quick_connect,
|
|
agent_hooks_host: crate::ui::host_ops::HostId::LOCAL,
|
|
agent_hooks_states: crate::ui::settings::AgentHooksView::Loading,
|
|
agent_hooks_seq: 0,
|
|
agent_hooks_note: None,
|
|
_subs: subs,
|
|
});
|
|
let search_focus = self
|
|
.settings
|
|
.as_ref()
|
|
.map(|s| s.search.read(cx).focus_handle(cx));
|
|
match search_focus {
|
|
Some(handle) => window.focus(&handle, cx),
|
|
None => window.focus(&focus_handle, cx),
|
|
}
|
|
self.rebuild_theme_editor(window, cx);
|
|
self.ensure_agent_hooks_loaded(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn build_font_selects(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> (
|
|
Entity<SelectState<SearchableVec<String>>>,
|
|
Entity<SelectState<SearchableVec<String>>>,
|
|
Entity<SelectState<SearchableVec<String>>>,
|
|
Entity<SelectState<SearchableVec<String>>>,
|
|
) {
|
|
let cfg = cx.global::<Config>();
|
|
let family = cfg.font_family.clone();
|
|
let font_bold = cfg.font_family_bold.clone();
|
|
let font_italic = cfg.font_family_italic.clone();
|
|
let ui_font_family = cfg.ui_font_family.clone();
|
|
let mut font_names = cx.text_system().all_font_names();
|
|
if !font_names.contains(&family) {
|
|
font_names.push(family.clone());
|
|
font_names.sort_unstable();
|
|
}
|
|
if let Some(ui_font) = &ui_font_family
|
|
&& !font_names.contains(ui_font)
|
|
{
|
|
font_names.push(ui_font.clone());
|
|
font_names.sort_unstable();
|
|
}
|
|
let selected_font_index = font_names
|
|
.iter()
|
|
.position(|n| *n == family)
|
|
.map(|row| IndexPath::default().row(row));
|
|
let font_select = cx.new(|cx| {
|
|
SelectState::new(
|
|
SearchableVec::new(font_names.clone()),
|
|
selected_font_index,
|
|
window,
|
|
cx,
|
|
)
|
|
.searchable(true)
|
|
});
|
|
let build_alt_font_select = |value: &Option<String>,
|
|
default_label: &str,
|
|
names: &[String],
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>| {
|
|
let mut rows = Vec::with_capacity(names.len() + 1);
|
|
rows.push(default_label.to_string());
|
|
rows.extend(names.iter().cloned());
|
|
let selected = value
|
|
.as_ref()
|
|
.and_then(|v| rows.iter().position(|n| n == v))
|
|
.unwrap_or(0);
|
|
cx.new(|cx| {
|
|
SelectState::new(
|
|
SearchableVec::new(rows),
|
|
Some(IndexPath::default().row(selected)),
|
|
window,
|
|
cx,
|
|
)
|
|
.searchable(true)
|
|
})
|
|
};
|
|
let alt_default = crate::ui::settings::font_default_label();
|
|
let font_bold_select =
|
|
build_alt_font_select(&font_bold, alt_default, &font_names, window, cx);
|
|
let font_italic_select =
|
|
build_alt_font_select(&font_italic, alt_default, &font_names, window, cx);
|
|
let ui_font_select = build_alt_font_select(
|
|
&ui_font_family,
|
|
crate::ui::settings::ui_font_default_label(),
|
|
&font_names,
|
|
window,
|
|
cx,
|
|
);
|
|
subs.push(cx.subscribe_in(
|
|
&font_select,
|
|
window,
|
|
|this, _select, ev: &SelectEvent<SearchableVec<String>>, _window, cx| {
|
|
if let SelectEvent::Confirm(Some(family)) = ev {
|
|
this.commit_font_family(family.clone(), cx);
|
|
}
|
|
},
|
|
));
|
|
subs.push(cx.subscribe_in(
|
|
&font_bold_select,
|
|
window,
|
|
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
|
|
if let SelectEvent::Confirm(Some(name)) = ev {
|
|
this.commit_font_family_emphasis(true, name.clone(), cx);
|
|
}
|
|
},
|
|
));
|
|
subs.push(cx.subscribe_in(
|
|
&font_italic_select,
|
|
window,
|
|
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
|
|
if let SelectEvent::Confirm(Some(name)) = ev {
|
|
this.commit_font_family_emphasis(false, name.clone(), cx);
|
|
}
|
|
},
|
|
));
|
|
subs.push(cx.subscribe_in(
|
|
&ui_font_select,
|
|
window,
|
|
|this, _s, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
|
|
if let SelectEvent::Confirm(Some(name)) = ev {
|
|
this.commit_ui_font_family(name.clone(), window, cx);
|
|
}
|
|
},
|
|
));
|
|
(
|
|
font_select,
|
|
font_bold_select,
|
|
font_italic_select,
|
|
ui_font_select,
|
|
)
|
|
}
|
|
|
|
fn build_language_select(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<SelectState<SearchableVec<String>>> {
|
|
let labels = || {
|
|
crate::ui::i18n::SUPPORTED_LANGUAGES
|
|
.iter()
|
|
.map(|lang| t(lang.label_key).to_string())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let cfg = cx.global::<Config>();
|
|
let current = Self::normalize_gui_language(&cfg.gui_language);
|
|
let rows = labels();
|
|
let selected = crate::ui::i18n::SUPPORTED_LANGUAGES
|
|
.iter()
|
|
.position(|lang| lang.code == current)
|
|
.unwrap_or(0);
|
|
let language_select = cx.new(|cx| {
|
|
SelectState::new(
|
|
SearchableVec::new(rows),
|
|
Some(IndexPath::default().row(selected)),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
subs.push(cx.subscribe_in(
|
|
&language_select,
|
|
window,
|
|
move |this, _select, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
|
|
if let SelectEvent::Confirm(Some(label)) = ev {
|
|
let rows = labels();
|
|
if let Some(idx) = rows.iter().position(|r| r == label) {
|
|
if let Some(lang) = crate::ui::i18n::SUPPORTED_LANGUAGES.get(idx) {
|
|
this.set_gui_language(lang.code, window, cx);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
));
|
|
language_select
|
|
}
|
|
|
|
fn normalize_gui_language(code: &str) -> &'static str {
|
|
crate::ui::i18n::find_language(code)
|
|
.map(|lang| lang.code)
|
|
.unwrap_or_else(crate::ui::i18n::default_language_code)
|
|
}
|
|
|
|
/// The backdrop dropdown only lists the presets this Windows build
|
|
/// supports, in the order of `theme::supported_backdrops`; the select
|
|
/// resolves the picked label back through that same list.
|
|
#[cfg(target_os = "windows")]
|
|
fn build_window_backdrop_select(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<SelectState<SearchableVec<String>>> {
|
|
let rows = window_backdrop_labels(cx.global::<Config>().window_backdrop);
|
|
let selected = window_backdrop_index(cx.global::<Config>().window_backdrop);
|
|
let select = cx.new(|cx| {
|
|
SelectState::new(
|
|
SearchableVec::new(rows),
|
|
Some(IndexPath::default().row(selected)),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
subs.push(cx.subscribe_in(
|
|
&select,
|
|
window,
|
|
move |this, _select, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
|
|
if let SelectEvent::Confirm(Some(label)) = ev {
|
|
let current = cx.global::<Config>().window_backdrop;
|
|
let rows = window_backdrop_labels(current);
|
|
if let Some(idx) = rows.iter().position(|row| row == label) {
|
|
this.set_window_backdrop(
|
|
window_backdrop_from_index(idx, current),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
));
|
|
select
|
|
}
|
|
|
|
pub(crate) fn set_gui_language(
|
|
&mut self,
|
|
code: &'static str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let code = Self::normalize_gui_language(code);
|
|
{
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.gui_language = code.to_string();
|
|
}
|
|
set_locale(code);
|
|
self.persist_settings_config(cx);
|
|
set_menus(cx);
|
|
// Explorer reads its menu wording from the registry, so it is the one
|
|
// surface a language change does not reach on its own. No-op unless
|
|
// the user installed the context menu, and off Windows entirely.
|
|
crate::core::explorer_context_menu::refresh_labels();
|
|
self.refresh_locale_state(window, cx);
|
|
crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace));
|
|
}
|
|
|
|
pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.sidebar_search.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::SearchTabs), window, cx)
|
|
});
|
|
self.file_search.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
|
|
});
|
|
// The remote Files panel is built once with the app, so its placeholder
|
|
// is the one input that would otherwise keep the old language.
|
|
self.sftp_panel.filter_input.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
|
|
});
|
|
if let Some(s) = self.active_settings() {
|
|
let rows = crate::ui::i18n::SUPPORTED_LANGUAGES
|
|
.iter()
|
|
.map(|lang| t(lang.label_key).to_string())
|
|
.collect::<Vec<_>>();
|
|
s.language_select.update(cx, |state, cx| {
|
|
state.set_items(SearchableVec::new(rows), window, cx);
|
|
let code = Self::normalize_gui_language(&cx.global::<Config>().gui_language);
|
|
let selected = crate::ui::i18n::SUPPORTED_LANGUAGES
|
|
.iter()
|
|
.position(|lang| lang.code == code)
|
|
.unwrap_or(0);
|
|
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
|
|
});
|
|
#[cfg(target_os = "windows")]
|
|
s.window_backdrop_select.update(cx, |state, cx| {
|
|
let current = cx.global::<Config>().window_backdrop;
|
|
let rows = window_backdrop_labels(current);
|
|
state.set_items(SearchableVec::new(rows), window, cx);
|
|
// `set_items` does not preserve the selection; restore the
|
|
// index of the stored value so a locale refresh (which
|
|
// re-translates the labels) cannot leave the dropdown
|
|
// showing no — or the wrong — selection.
|
|
state.set_selected_index(
|
|
Some(IndexPath::default().row(window_backdrop_index(current))),
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
s.search.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::SearchSettings), window, cx)
|
|
});
|
|
s.theme_search.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::SearchThemes), window, cx)
|
|
});
|
|
s.ssh_filter.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::FilterHosts), window, cx)
|
|
});
|
|
s.ssh_quick_connect.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::AppPlaceholderSshQuickConnect), window, cx)
|
|
});
|
|
s.shell_args_input.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::AppPlaceholderNone), window, cx)
|
|
});
|
|
if !cfg!(windows) {
|
|
s.shell_program_input.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::AppPlaceholderLoginShell), window, cx)
|
|
});
|
|
}
|
|
s.link_file_command_input.update(cx, |state, cx| {
|
|
state.set_placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp), window, cx)
|
|
});
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn build_shell_inputs(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> (Entity<InputState>, Entity<InputState>, Entity<InputState>) {
|
|
let cfg = cx.global::<Config>();
|
|
let (shell_program, shell_args) = match &cfg.shell {
|
|
// Quote each argument back into field text rather than joining on
|
|
// spaces: `join(" ")` cannot spell an argument that contains one,
|
|
// so a perfectly legal `"args": ["-c", "echo hi"]` in config.json
|
|
// refilled as three words and re-committed as three argv the moment
|
|
// the field lost focus — the user never typed a thing (#551).
|
|
// `join_shell_args` quotes only what needs it, and `commit_shell`'s
|
|
// matching `split_shell_args` parses it back losslessly.
|
|
Some(s) => (s.program.clone(), join_shell_args(&s.args)),
|
|
None => (String::new(), String::new()),
|
|
};
|
|
let wd_path = cfg.working_directory.path.clone();
|
|
let platform_default = if cfg!(windows) {
|
|
"PowerShell"
|
|
} else {
|
|
t(L10nKey::AppPlaceholderLoginShell)
|
|
};
|
|
let shell_program_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(platform_default)
|
|
.default_value(shell_program)
|
|
});
|
|
let shell_args_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t(L10nKey::AppPlaceholderNone))
|
|
.default_value(shell_args)
|
|
});
|
|
let wd_path_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("/path/to/directory")
|
|
.default_value(wd_path)
|
|
});
|
|
let commit_shell = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
|
|
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
|
|
this.commit_shell(cx);
|
|
}
|
|
};
|
|
let commit_wd = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
|
|
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
|
|
this.commit_working_directory_path(cx);
|
|
}
|
|
};
|
|
subs.push(
|
|
cx.subscribe_in(&shell_program_input, window, move |this, _i, ev, _w, cx| {
|
|
commit_shell(this, ev, cx)
|
|
}),
|
|
);
|
|
subs.push(
|
|
cx.subscribe_in(&shell_args_input, window, move |this, _i, ev, _w, cx| {
|
|
commit_shell(this, ev, cx)
|
|
}),
|
|
);
|
|
subs.push(
|
|
cx.subscribe_in(&wd_path_input, window, move |this, _i, ev, _w, cx| {
|
|
commit_wd(this, ev, cx)
|
|
}),
|
|
);
|
|
(shell_program_input, shell_args_input, wd_path_input)
|
|
}
|
|
|
|
fn build_link_file_command_input(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<InputState> {
|
|
let value = cx
|
|
.global::<Config>()
|
|
.link_file_command
|
|
.clone()
|
|
.unwrap_or_default();
|
|
let input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp))
|
|
.default_value(value)
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
|
|
this.commit_link_file_command(cx);
|
|
}
|
|
}),
|
|
);
|
|
input
|
|
}
|
|
|
|
fn build_http_proxy_input(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<InputState> {
|
|
let value = cx.global::<Config>().http_proxy.clone().unwrap_or_default();
|
|
let input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("http://127.0.0.1:7890")
|
|
.default_value(value)
|
|
});
|
|
subs.push(
|
|
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
|
|
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
|
|
this.commit_http_proxy(cx);
|
|
}
|
|
}),
|
|
);
|
|
input
|
|
}
|
|
|
|
fn commit_http_proxy(&mut self, cx: &mut Context<Self>) {
|
|
let Some(value) = self
|
|
.active_settings()
|
|
.map(|s| s.http_proxy_input.read(cx).value().trim().to_string())
|
|
else {
|
|
return;
|
|
};
|
|
// Keep an unusable value out of `config.json`. The row renders a hint
|
|
// under the input, so the typo does not silently vanish either.
|
|
if !value.is_empty() && !tty7_core::daemon::install::proxy::is_valid_manual(&value) {
|
|
cx.notify();
|
|
return;
|
|
}
|
|
let value = (!value.is_empty()).then_some(value);
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.http_proxy == value {
|
|
return;
|
|
}
|
|
cfg.http_proxy = value;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_link_file_command(&mut self, cx: &mut Context<Self>) {
|
|
let Some(command) = self.active_settings().map(|s| {
|
|
s.link_file_command_input
|
|
.read(cx)
|
|
.value()
|
|
.trim()
|
|
.to_string()
|
|
}) else {
|
|
return;
|
|
};
|
|
let command = if command.is_empty() {
|
|
None
|
|
} else {
|
|
Some(command)
|
|
};
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.link_file_command == command {
|
|
return;
|
|
}
|
|
cfg.link_file_command = command;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn build_window_opacity_slider(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<SliderState> {
|
|
let eff = Self::effective_window_opacity(cx);
|
|
let slider = cx.new(|_| {
|
|
SliderState::new()
|
|
.min(0.2)
|
|
.max(1.0)
|
|
.step(0.01)
|
|
.default_value(eff)
|
|
});
|
|
subs.push(cx.subscribe_in(
|
|
&slider,
|
|
window,
|
|
|this, _s, ev: &SliderEvent, window, cx| match ev {
|
|
SliderEvent::Change(v) => {
|
|
cx.global_mut::<Config>().window_opacity = Some(v.start().clamp(0.2, 1.0));
|
|
apply_theme(Some(window), cx);
|
|
cx.notify();
|
|
}
|
|
SliderEvent::Release(_) => this.persist_settings_config(cx),
|
|
},
|
|
));
|
|
slider
|
|
}
|
|
|
|
fn build_scroll_slider(
|
|
&mut self,
|
|
subs: &mut Vec<Subscription>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<SliderState> {
|
|
let scroll_mult = cx.global::<Config>().mouse_scroll_multiplier;
|
|
let scroll_slider = cx.new(|_| {
|
|
SliderState::new()
|
|
.min(0.5)
|
|
.max(5.0)
|
|
.step(0.25)
|
|
.default_value(scroll_mult)
|
|
});
|
|
subs.push(cx.subscribe_in(
|
|
&scroll_slider,
|
|
window,
|
|
|this, _s, ev: &SliderEvent, _w, cx| match ev {
|
|
SliderEvent::Change(v) => {
|
|
cx.global_mut::<Config>().mouse_scroll_multiplier = v.start().clamp(0.1, 10.0);
|
|
cx.notify();
|
|
}
|
|
SliderEvent::Release(_) => this.persist_settings_config(cx),
|
|
},
|
|
));
|
|
scroll_slider
|
|
}
|
|
|
|
pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(s) = self.settings.take() {
|
|
self.last_settings_location = (
|
|
s.section,
|
|
if s.search_active {
|
|
s.search_return_offset
|
|
} else {
|
|
s.content_scroll.offset()
|
|
},
|
|
);
|
|
self.focus_active(window, cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn open_settings_section(
|
|
&mut self,
|
|
section: SettingsSection,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.settings.is_none() {
|
|
self.toggle_settings(window, cx);
|
|
}
|
|
self.navigate_settings(section, None, window, cx);
|
|
}
|
|
|
|
/// Resolve a pending form before performing both parts of an external
|
|
/// navigation. Opening the page and loading its form must be one action.
|
|
pub(crate) fn open_ssh_profile_form(
|
|
&mut self,
|
|
profile: crate::core::ssh_profile::SshProfile,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
|
|
this.open_settings_section(SettingsSection::Ssh, window, cx);
|
|
this.ssh_form_load(&profile, window, cx);
|
|
});
|
|
}
|
|
|
|
pub(crate) fn open_ssh_profile_in_settings(
|
|
&mut self,
|
|
id: uuid::Uuid,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
|
|
// Read after resolving edits, so saving and reopening the same
|
|
// profile cannot load a snapshot from before that save.
|
|
if let Some(profile) = cx
|
|
.global::<Config>()
|
|
.ssh_profiles
|
|
.iter()
|
|
.find(|p| p.id == id)
|
|
.cloned()
|
|
{
|
|
this.open_ssh_profile_form(profile, window, cx);
|
|
} else {
|
|
this.open_settings_section(SettingsSection::Ssh, window, cx);
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(crate) fn open_ssh_profile_new_from_target(
|
|
&mut self,
|
|
target: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut profile = crate::core::ssh_profile::SshProfile::new(String::new());
|
|
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
|
|
profile.port = qc.port_or_default();
|
|
profile.host = qc.host;
|
|
if let Some(user) = qc.user {
|
|
profile.user = user;
|
|
}
|
|
if profile.name.is_empty() {
|
|
profile.name = profile.host.clone();
|
|
}
|
|
}
|
|
self.open_ssh_profile_form(profile, window, cx);
|
|
}
|
|
|
|
fn commit_font_family(&mut self, family: String, cx: &mut Context<Self>) {
|
|
self.font_family = family.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let family = family.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
|
|
}
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
cfg.font_family = family;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_font_family_emphasis(&mut self, bold: bool, name: String, cx: &mut Context<Self>) {
|
|
let family = (name != crate::ui::settings::font_default_label()).then_some(name);
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let family = family.clone();
|
|
leaf.update(cx, |v, cx| {
|
|
if bold {
|
|
v.set_font_family_bold(family, cx);
|
|
} else {
|
|
v.set_font_family_italic(family, cx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
if bold {
|
|
self.font_family_bold = family.clone();
|
|
} else {
|
|
self.font_family_italic = family.clone();
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
if bold {
|
|
cfg.font_family_bold = family;
|
|
} else {
|
|
cfg.font_family_italic = family;
|
|
}
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_ui_font_family(&mut self, name: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
let family = (name != crate::ui::settings::ui_font_default_label()).then_some(name);
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.ui_font_family == family {
|
|
return;
|
|
}
|
|
cfg.ui_font_family = family;
|
|
self.persist_settings_config(cx);
|
|
apply_theme(Some(window), cx);
|
|
cx.refresh_windows();
|
|
cx.notify();
|
|
}
|
|
|
|
fn reload_from_config(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
apply_theme(Some(window), cx);
|
|
self.sync_window_opacity_slider(window, cx);
|
|
// Another window — or a hand edit / config sync picked up by the
|
|
// `Config` watcher — can change the backdrop while this window's
|
|
// settings panel is open. The window itself already switched
|
|
// material above, so the dropdown has to follow or it contradicts
|
|
// what it describes.
|
|
#[cfg(target_os = "windows")]
|
|
self.sync_window_backdrop_select(window, cx);
|
|
let config = cx.global::<Config>().clone();
|
|
if config.cursor_style != self.terminal_cursor_style
|
|
|| config.scrollback_limit != self.terminal_scrollback_limit
|
|
{
|
|
self.terminal_cursor_style = config.cursor_style;
|
|
self.terminal_scrollback_limit = config.scrollback_limit;
|
|
self.apply_terminal_config_to_panes(&config, cx);
|
|
}
|
|
let (font_size, line_height, font_family, font_fallbacks, font_features) = {
|
|
let cfg = cx.global::<Config>();
|
|
(
|
|
cfg.font_size,
|
|
cfg.line_height,
|
|
cfg.font_family.clone(),
|
|
cfg.font_fallbacks.clone(),
|
|
cfg.font_features
|
|
.as_ref()
|
|
.map(crate::core::config::gpui_font_features),
|
|
)
|
|
};
|
|
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
|
|
self.right_panel_width
|
|
.set(cx.global::<Config>().right_panel_width);
|
|
self.document_ratio
|
|
.set(cx.global::<Config>().document_ratio);
|
|
if font_size != self.font_size {
|
|
self.font_size = font_size;
|
|
let px_size = px(font_size);
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.font_size = px_size;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if line_height != self.line_height {
|
|
self.line_height = line_height;
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.line_height_mul = line_height;
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if font_family != self.font_family {
|
|
self.font_family = font_family.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let family = font_family.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
|
|
}
|
|
}
|
|
}
|
|
// A `font_fallbacks` edit on its own reached no live pane at all: the
|
|
// chain is built once per view and from then on only cloned around.
|
|
// `set_font_family` rereads it too, so an edit that moves both ends up
|
|
// building the same chain twice rather than disagreeing about it.
|
|
if font_fallbacks != self.font_fallbacks {
|
|
self.font_fallbacks = font_fallbacks;
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
v.reread_fallback_chain(cx);
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if font_features != self.font_features {
|
|
self.font_features = font_features.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let features = font_features.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
|
|
}
|
|
}
|
|
}
|
|
let (bold, italic) = {
|
|
let cfg = cx.global::<Config>();
|
|
(cfg.font_family_bold.clone(), cfg.font_family_italic.clone())
|
|
};
|
|
if bold != self.font_family_bold {
|
|
self.font_family_bold = bold.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let bold = bold.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_family_bold(bold, cx));
|
|
}
|
|
}
|
|
}
|
|
if italic != self.font_family_italic {
|
|
self.font_family_italic = italic.clone();
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
let italic = italic.clone();
|
|
leaf.update(cx, |v, cx| v.set_font_family_italic(italic, cx));
|
|
}
|
|
}
|
|
}
|
|
let report_mouse = cx.global::<Config>().mouse_reporting;
|
|
let prompt_editor = cx.global::<Config>().prompt_editor;
|
|
for tab in &self.tabs {
|
|
for leaf in tab.pane.terminals() {
|
|
leaf.update(cx, |v, cx| {
|
|
if v.report_mouse != report_mouse {
|
|
v.report_mouse = report_mouse;
|
|
cx.notify();
|
|
}
|
|
// A hand edit of `config.json` — or another window's
|
|
// settings page — has to move a live pane between the
|
|
// local editor and ZLE too, not just the window that
|
|
// flipped the switch.
|
|
v.set_prompt_editor(prompt_editor, cx);
|
|
});
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// Saves the shell after the detected-shell menu wrote into the field.
|
|
///
|
|
/// The field itself only commits on Enter or blur, and picking from a menu
|
|
/// is neither — without this the choice would sit in the box unsaved until
|
|
/// the user happened to click into it and out again.
|
|
pub(crate) fn commit_shell_from_picker(&mut self, cx: &mut Context<Self>) {
|
|
self.commit_shell(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_shell(&mut self, cx: &mut Context<Self>) {
|
|
let Some(settings) = self.active_settings() else {
|
|
return;
|
|
};
|
|
let program = settings
|
|
.shell_program_input
|
|
.read(cx)
|
|
.value()
|
|
.trim()
|
|
.to_string();
|
|
// Split the way a command line splits, not on raw whitespace: the text
|
|
// goes on to become argv verbatim, and `split_whitespace` kept the
|
|
// quote characters while tearing the quoted string apart — `-c "echo
|
|
// hi"` reached the shell as `["-c", "\"echo", "hi\""]` (#551).
|
|
let raw_args = settings.shell_args_input.read(cx).value();
|
|
let args: Vec<String> = match split_shell_args(&raw_args) {
|
|
Ok(args) => args,
|
|
// A quote that never closes names no argv at all, so this text
|
|
// cannot go into `config.json` — the proxy row's pattern: leave the
|
|
// stored arguments alone and let the row explain why under the
|
|
// input after this notify. The rest of the row still commits: the
|
|
// Program the user just typed, or picked from the menu, is not
|
|
// theirs to lose over the field below it.
|
|
Err(_) => {
|
|
cx.notify();
|
|
let cfg = cx.global::<Config>();
|
|
cfg.shell
|
|
.as_ref()
|
|
.map(|s| s.args.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
};
|
|
let shell = if program.is_empty() {
|
|
None
|
|
} else {
|
|
Some(ShellConfig { program, args })
|
|
};
|
|
{
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.shell == shell {
|
|
return;
|
|
}
|
|
cfg.shell = shell;
|
|
self.persist_settings_config(cx);
|
|
}
|
|
// Shell discovery runs off the UI thread and now includes the saved
|
|
// configured shell, so refresh the menu without blocking Settings.
|
|
self.refresh_shells(cx);
|
|
}
|
|
|
|
pub(crate) fn set_working_directory_strategy(
|
|
&mut self,
|
|
strategy: crate::core::config::WdStrategy,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.working_directory.strategy == strategy {
|
|
return;
|
|
}
|
|
cfg.working_directory.strategy = strategy;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn commit_working_directory_path(&mut self, cx: &mut Context<Self>) {
|
|
let Some(path) = self
|
|
.active_settings()
|
|
.map(|s| s.wd_path_input.read(cx).value().trim().to_string())
|
|
else {
|
|
return;
|
|
};
|
|
// A typo here is not a directory, and the daemon then silently falls
|
|
// back to its own cwd for every new pane — "new shells don't start in
|
|
// my project" reads as a tty7 bug rather than a typo (#601). Refuse
|
|
// to save, the proxy row's pattern (#551): the field keeps the text,
|
|
// the settings row explains in red, and the last good value stays in
|
|
// config.json.
|
|
if !wd_path_saveable(&path) {
|
|
cx.notify();
|
|
return;
|
|
}
|
|
let cfg = cx.global_mut::<Config>();
|
|
if cfg.working_directory.path == path {
|
|
return;
|
|
}
|
|
cfg.working_directory.path = path;
|
|
self.persist_settings_config(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn active_settings(&self) -> Option<&SettingsState> {
|
|
self.settings.as_ref()
|
|
}
|
|
|
|
pub(crate) fn active_settings_mut(&mut self) -> Option<&mut SettingsState> {
|
|
self.settings.as_mut()
|
|
}
|
|
|
|
pub(crate) fn tab_ssh_dot(&self, tab: &Tab, cx: &App) -> Option<u32> {
|
|
use crate::daemon::protocol::SshPhase;
|
|
let leaf = tab.pane.first_leaf()?;
|
|
let v = leaf.terminal()?.read(cx);
|
|
if let Some(phase) = v.ssh_phase() {
|
|
let rgb = if v.ssh_disconnected() {
|
|
0xEF4444
|
|
} else {
|
|
match phase {
|
|
SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B,
|
|
SshPhase::Connected => 0x22C55E,
|
|
SshPhase::Failed { .. } => 0xEF4444,
|
|
}
|
|
};
|
|
Some(rgb)
|
|
} else if v
|
|
.remote_context()
|
|
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
|
|
{
|
|
Some(0x9CA3AF)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn leaf_is_warn_ssh(&self, leaf: &Entity<TerminalView>, cx: &App) -> bool {
|
|
use crate::daemon::protocol::SshPhase;
|
|
let v = leaf.read(cx);
|
|
let connected = matches!(v.ssh_phase(), Some(SshPhase::Connected)) && !v.terminal.exited;
|
|
if !connected {
|
|
return false;
|
|
}
|
|
let cfg = cx.global::<Config>();
|
|
let per_profile = v
|
|
.ssh_spec()
|
|
.and_then(|s| s.profile_id.clone())
|
|
.and_then(|id| uuid::Uuid::parse_str(&id).ok())
|
|
.and_then(|id| cfg.ssh_profiles.iter().find(|p| p.id == id))
|
|
.and_then(|p| p.warn_on_close);
|
|
per_profile.unwrap_or(cfg.ssh_warn_on_close)
|
|
}
|
|
|
|
/// The app asks every other question of this class through the platform's
|
|
/// own dialog. This one used to be a bespoke in-app card with no scrim, no
|
|
/// Escape and no click-outside — the two buttons were the only way out.
|
|
fn ask_before_closing(
|
|
&mut self,
|
|
target: CloseTarget,
|
|
reason: CloseReason,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.close_prompt_open {
|
|
return;
|
|
}
|
|
self.close_prompt_open = true;
|
|
// ⌘W closes a pane, but when it is the only one in its tab the tab goes
|
|
// with it — the question has to name what actually disappears.
|
|
let ends_the_tab = matches!(target, CloseTarget::Tab(_))
|
|
|| self
|
|
.tabs
|
|
.get(self.active)
|
|
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
|
|
let (title, body) = close_prompt(ends_the_tab, &reason);
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
&title,
|
|
Some(&body),
|
|
&crate::ui::confirm_answers(
|
|
t(crate::ui::i18n::L10nKey::Close),
|
|
t(crate::ui::i18n::L10nKey::Keep),
|
|
),
|
|
cx,
|
|
);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let close = matches!(answer.await, Ok(0));
|
|
let _ = this.update_in(cx, |this, window, cx| {
|
|
this.close_prompt_open = false;
|
|
// Cancelled, or the window went away with the question open:
|
|
// either way the work carries on.
|
|
if !close {
|
|
cx.notify();
|
|
return;
|
|
}
|
|
match target {
|
|
// The tab may have moved, or gone, while the question was
|
|
// up; find it by id and let it be if it is already closed.
|
|
CloseTarget::Tab(id) => {
|
|
if let Some(i) = this.tabs.iter().position(|t| t.tree_id.get() == id) {
|
|
this.close_tab_inner(i, true, window, cx);
|
|
}
|
|
}
|
|
CloseTarget::Pane => this.close_pane_inner(true, window, cx),
|
|
}
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// The first reason this pane should not simply vanish.
|
|
fn leaf_close_reason(&self, leaf: &Entity<TerminalView>, cx: &App) -> Option<CloseReason> {
|
|
if self.leaf_is_warn_ssh(leaf, cx) {
|
|
return Some(CloseReason::LiveSsh);
|
|
}
|
|
leaf.read(cx).busy().map(CloseReason::Busy)
|
|
}
|
|
|
|
/// Whether closing this tab would drop a connection the user asked to be
|
|
/// warned about. Narrower than [`Self::tab_close_reason`] on purpose — see
|
|
/// the bulk closes, which skip these and only these.
|
|
fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool {
|
|
self.tabs.get(index).is_some_and(|tab| {
|
|
tab.pane
|
|
.terminals()
|
|
.iter()
|
|
.any(|l| self.leaf_is_warn_ssh(l, cx))
|
|
})
|
|
}
|
|
|
|
fn tab_close_reason(&self, index: usize, cx: &App) -> Option<CloseReason> {
|
|
self.tabs
|
|
.get(index)?
|
|
.pane
|
|
.terminals()
|
|
.iter()
|
|
.find_map(|l| self.leaf_close_reason(l, cx))
|
|
}
|
|
|
|
fn focused_pane_close_reason(&self, window: &Window, cx: &App) -> Option<CloseReason> {
|
|
let leaf = self
|
|
.tabs
|
|
.get(self.active)?
|
|
.pane
|
|
.focused_or_first(window, cx)?;
|
|
self.leaf_close_reason(&leaf, cx)
|
|
}
|
|
|
|
pub(crate) fn active_ssh_pane(
|
|
&self,
|
|
window: &Window,
|
|
cx: &App,
|
|
) -> Option<(u64, RemoteContext)> {
|
|
let pane = self
|
|
.tabs
|
|
.get(self.active)?
|
|
.pane
|
|
.focused_or_first(window, cx)?;
|
|
let pane = pane.read(cx);
|
|
let remote = pane.remote_context()?;
|
|
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
|
|
}
|
|
|
|
pub(crate) fn active_connected_native_ssh_pane(
|
|
&self,
|
|
window: &Window,
|
|
cx: &App,
|
|
) -> Option<(u64, RemoteContext)> {
|
|
use crate::daemon::protocol::{RemoteKind, SshPhase};
|
|
let (pane_id, remote) = self.active_ssh_pane(window, cx)?;
|
|
if remote.kind != RemoteKind::NativeSsh {
|
|
return None;
|
|
}
|
|
let leaf = self
|
|
.tabs
|
|
.get(self.active)?
|
|
.pane
|
|
.focused_or_first(window, cx)?;
|
|
matches!(leaf.read(cx).ssh_phase(), Some(SshPhase::Connected)).then_some((pane_id, remote))
|
|
}
|
|
|
|
pub(crate) fn select_settings_section(
|
|
&mut self,
|
|
target: SettingsSection,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
let moved = s.section != target;
|
|
s.section = target;
|
|
s.recording = None;
|
|
// Arriving on a page with a query live means arriving to look for
|
|
// what the nav badge counted, so take the page to it.
|
|
if moved {
|
|
s.reveal_first_hit.set(true);
|
|
}
|
|
if target == SettingsSection::Agents {
|
|
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
|
|
}
|
|
}
|
|
self.ensure_agent_hooks_loaded(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn ensure_agent_hooks_loaded(&mut self, cx: &mut Context<Self>) {
|
|
if self
|
|
.active_settings()
|
|
.is_some_and(|s| s.section == SettingsSection::Agents)
|
|
{
|
|
self.load_agent_hooks_states(cx);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn agent_hooks_machines(
|
|
&self,
|
|
cx: &mut App,
|
|
) -> Vec<crate::ui::settings::AgentHooksMachine> {
|
|
use crate::ui::settings::AgentHooksMachine;
|
|
let mut out = vec![AgentHooksMachine {
|
|
host: crate::ui::host_ops::HostId::LOCAL,
|
|
label: t(L10nKey::AppAgentHooksThisComputer).to_string(),
|
|
}];
|
|
let configured = crate::ui::remote_connect::available_hosts(cx);
|
|
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
|
|
if id.is_local() {
|
|
continue;
|
|
}
|
|
let label = configured
|
|
.iter()
|
|
.find(|h| h.target.host_id() == id)
|
|
.map(|h| h.label.clone())
|
|
.unwrap_or_else(|| t(L10nKey::AppAgentHooksRemoteMachine).to_string());
|
|
out.push(AgentHooksMachine { host: id, label });
|
|
}
|
|
out
|
|
}
|
|
|
|
pub(crate) fn agent_hooks_offline_count(&self, cx: &mut App) -> usize {
|
|
let connected = crate::ui::host_registry::HostRegistry::ids(cx);
|
|
cx.global::<Config>()
|
|
.ssh_profiles
|
|
.iter()
|
|
.filter(|p| {
|
|
!connected
|
|
.contains(&crate::core::session::RemoteTarget::Profile { id: p.id }.host_id())
|
|
})
|
|
.count()
|
|
}
|
|
|
|
pub(crate) fn select_agent_hooks_host(
|
|
&mut self,
|
|
host: crate::ui::host_ops::HostId,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
if s.agent_hooks_host == host {
|
|
return;
|
|
}
|
|
s.agent_hooks_host = host;
|
|
s.agent_hooks_note = None;
|
|
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
|
|
}
|
|
self.load_agent_hooks_states(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn load_agent_hooks_states(&mut self, cx: &mut Context<Self>) {
|
|
use crate::core::agent_hooks::{HookAgent, HookTarget};
|
|
use crate::ui::settings::{AgentHookRow, AgentHooksView};
|
|
|
|
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
|
|
return;
|
|
};
|
|
let seq = match self.settings.as_mut() {
|
|
Some(s) => {
|
|
s.agent_hooks_seq += 1;
|
|
s.agent_hooks_seq
|
|
}
|
|
None => return,
|
|
};
|
|
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
s.agent_hooks_states = AgentHooksView::Unavailable(Self::agent_hooks_offline_msg());
|
|
}
|
|
cx.notify();
|
|
return;
|
|
};
|
|
|
|
crate::ui::host_ops::HostOps::run(
|
|
host,
|
|
cx,
|
|
move |h| {
|
|
let target = match &home {
|
|
Some(home) => HookTarget::remote(h, home.clone()),
|
|
None => HookTarget::local(h)?,
|
|
};
|
|
Some(
|
|
HookAgent::ALL
|
|
.into_iter()
|
|
.map(|agent| AgentHookRow {
|
|
agent,
|
|
state: crate::core::agent_hooks::hooks_state(&target, agent),
|
|
target: agent.target_display(&target),
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
},
|
|
move |this, rows, cx| {
|
|
if let Some(s) = this.settings.as_mut()
|
|
&& s.agent_hooks_seq == seq
|
|
{
|
|
s.agent_hooks_states = match rows {
|
|
Some(rows) => AgentHooksView::Ready(rows),
|
|
None => AgentHooksView::Unavailable(
|
|
t(L10nKey::AppAgentHooksNoHomeDir).to_string(),
|
|
),
|
|
};
|
|
cx.notify();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn agent_hooks_offline_msg() -> String {
|
|
t(L10nKey::AppAgentHooksOffline).to_string()
|
|
}
|
|
|
|
fn agent_hooks_link(
|
|
&self,
|
|
host_id: crate::ui::host_ops::HostId,
|
|
cx: &mut App,
|
|
) -> Option<(crate::ui::host_ops::SharedHost, Option<std::path::PathBuf>)> {
|
|
let host = crate::ui::host_registry::HostRegistry::get(cx, host_id)?;
|
|
if host_id.is_local() {
|
|
return Some((host, None));
|
|
}
|
|
if !host.is_connected() {
|
|
return None;
|
|
}
|
|
let home = crate::ui::remote_connect::HostLinks::home(cx, host_id)?;
|
|
Some((host, Some(home)))
|
|
}
|
|
|
|
pub(crate) fn settings_install_agent_hooks(
|
|
&mut self,
|
|
agent: crate::core::agent_hooks::HookAgent,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.run_agent_hooks_action(agent, true, cx);
|
|
}
|
|
|
|
pub(crate) fn settings_uninstall_agent_hooks(
|
|
&mut self,
|
|
agent: crate::core::agent_hooks::HookAgent,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.run_agent_hooks_action(agent, false, cx);
|
|
}
|
|
|
|
/// Words a hook install or removal for the note in Settings.
|
|
///
|
|
/// `agent_hooks` returns what it did rather than a sentence, because it
|
|
/// lives in `tty7-core` and cannot reach `src/ui/i18n` — it used to hand
|
|
/// back English prose, which a Chinese or Japanese UI then showed as-is.
|
|
fn agent_hooks_outcome_msg(outcome: &crate::core::agent_hooks::HookOutcome) -> String {
|
|
use crate::core::agent_hooks::HookOutcome as O;
|
|
match outcome {
|
|
O::Installed => t(L10nKey::AppAgentHooksInstalled).to_string(),
|
|
O::InstalledEnableCodexThere => {
|
|
t(L10nKey::AppAgentHooksInstalledEnableCodexThere).to_string()
|
|
}
|
|
O::InstalledCodexEnableFailed(e) => t_fmt(
|
|
L10nKey::AppAgentHooksInstalledCodexEnableFailed,
|
|
&[("error", e)],
|
|
),
|
|
O::Removed => t(L10nKey::AppAgentHooksRemoved).to_string(),
|
|
O::NothingInstalled => t(L10nKey::AppAgentHooksNothingInstalled).to_string(),
|
|
O::NoTty7Hooks => t(L10nKey::AppAgentHooksNoTty7Hooks).to_string(),
|
|
}
|
|
}
|
|
|
|
fn run_agent_hooks_action(
|
|
&mut self,
|
|
agent: crate::core::agent_hooks::HookAgent,
|
|
install: bool,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
use crate::core::agent_hooks::HookTarget;
|
|
|
|
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
|
|
return;
|
|
};
|
|
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
s.agent_hooks_note = Some((agent, Self::agent_hooks_offline_msg()));
|
|
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Unavailable(
|
|
Self::agent_hooks_offline_msg(),
|
|
);
|
|
}
|
|
cx.notify();
|
|
return;
|
|
};
|
|
|
|
crate::ui::host_ops::HostOps::run(
|
|
host,
|
|
cx,
|
|
move |h| {
|
|
let target = match &home {
|
|
Some(home) => HookTarget::remote(h, home.clone()),
|
|
None => HookTarget::local(h).ok_or_else(|| {
|
|
anyhow::anyhow!("{}", t(L10nKey::AppAgentHooksHomeDirUnresolved))
|
|
})?,
|
|
};
|
|
if install {
|
|
crate::core::agent_hooks::install_hooks(&target, agent)
|
|
} else {
|
|
crate::core::agent_hooks::uninstall_hooks(&target, agent)
|
|
}
|
|
},
|
|
move |this, result, cx| {
|
|
if let Some(s) = this.settings.as_mut() {
|
|
s.agent_hooks_note = Some((
|
|
agent,
|
|
match result {
|
|
Ok(outcome) => Self::agent_hooks_outcome_msg(&outcome),
|
|
// Every sibling error names its action; this one
|
|
// said only "Failed:", leaving the note that
|
|
// reports it silent about which half of the
|
|
// toggle had not happened.
|
|
Err(e) => t_fmt(
|
|
match install {
|
|
true => L10nKey::AppAgentHooksInstallFailed,
|
|
false => L10nKey::AppAgentHooksRemoveFailed,
|
|
},
|
|
&[("error", &e.to_string())],
|
|
),
|
|
},
|
|
));
|
|
}
|
|
this.load_agent_hooks_states(cx);
|
|
cx.notify();
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) fn autoselect_settings_search(&mut self, cx: &mut Context<Self>) {
|
|
if let Some(s) = self.settings.as_mut() {
|
|
let active = !s.search.read(cx).value().trim().is_empty() || s.modified_only;
|
|
if active && !s.search_active {
|
|
s.search_return_offset = s.content_scroll.offset();
|
|
}
|
|
if active {
|
|
s.content_scroll.set_offset(gpui::point(px(0.), px(0.)));
|
|
} else if s.search_active {
|
|
s.content_scroll.set_offset(s.search_return_offset);
|
|
}
|
|
s.search_active = active;
|
|
s.search_selection = 0;
|
|
if active {
|
|
s.focused_setting = None;
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn start_recording_key(
|
|
&mut self,
|
|
action: String,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let this = cx.weak_entity();
|
|
let intercept = cx.intercept_keystrokes(move |ev, _window, cx| {
|
|
let keystroke = ev.keystroke.clone();
|
|
let _ = this.update(cx, |this, cx| this.on_record_key(&keystroke, cx));
|
|
cx.stop_propagation();
|
|
});
|
|
self.record_gen = self.record_gen.wrapping_add(1);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.rebinding_note = None;
|
|
s.recording = Some(Recording {
|
|
action,
|
|
chords: Vec::new(),
|
|
_intercept: intercept,
|
|
});
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn on_record_key(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
|
|
let Some((action, has_chords)) = self
|
|
.active_settings()
|
|
.and_then(|s| s.recording.as_ref())
|
|
.map(|r| (r.action.clone(), !r.chords.is_empty()))
|
|
else {
|
|
return;
|
|
};
|
|
match keystroke.key.as_str() {
|
|
"escape" => {
|
|
self.stop_recording(cx);
|
|
return;
|
|
}
|
|
"backspace" | "delete" => {
|
|
if has_chords {
|
|
if let Some(r) = self
|
|
.active_settings_mut()
|
|
.and_then(|s| s.recording.as_mut())
|
|
{
|
|
r.chords.pop();
|
|
}
|
|
let still_has = self
|
|
.active_settings()
|
|
.and_then(|s| s.recording.as_ref())
|
|
.is_some_and(|r| !r.chords.is_empty());
|
|
if still_has {
|
|
self.schedule_recording_commit(cx);
|
|
} else {
|
|
self.record_gen = self.record_gen.wrapping_add(1);
|
|
}
|
|
cx.notify();
|
|
} else {
|
|
self.stop_recording(cx);
|
|
self.unbind_keybinding(action, cx);
|
|
}
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else {
|
|
return;
|
|
};
|
|
if let Some(r) = self
|
|
.active_settings_mut()
|
|
.and_then(|s| s.recording.as_mut())
|
|
{
|
|
r.chords.push(spec);
|
|
}
|
|
self.schedule_recording_commit(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn schedule_recording_commit(&mut self, cx: &mut Context<Self>) {
|
|
self.record_gen = self.record_gen.wrapping_add(1);
|
|
let generation = self.record_gen;
|
|
cx.spawn(async move |this, cx| {
|
|
smol::Timer::after(std::time::Duration::from_millis(RECORD_COMMIT_DELAY_MS)).await;
|
|
let _ = this.update(cx, |this, cx| {
|
|
if this.record_gen == generation {
|
|
this.commit_recording(cx);
|
|
}
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn commit_recording(&mut self, cx: &mut Context<Self>) {
|
|
let Some((action, chords)) = self
|
|
.active_settings()
|
|
.and_then(|s| s.recording.as_ref())
|
|
.filter(|r| !r.chords.is_empty())
|
|
.map(|r| (r.action.clone(), r.chords.clone()))
|
|
else {
|
|
return;
|
|
};
|
|
self.stop_recording(cx);
|
|
self.assign_keybinding(action, chords.join(" "), cx);
|
|
}
|
|
|
|
fn stop_recording(&mut self, cx: &mut Context<Self>) {
|
|
self.record_gen = self.record_gen.wrapping_add(1);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.recording = None;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) {
|
|
// Compared as chords, not as spellings: a recorded `secondary-}` and a
|
|
// config's `secondary-shift-]` are one keystroke written two ways, and
|
|
// only `same_chord` sees it. Compared as text, the displacement never
|
|
// fires and both bindings survive onto that keystroke, where which one
|
|
// wins is arbitrary (#750).
|
|
//
|
|
// Only that chord moves: the action that had it keeps any others it
|
|
// has, since an action can carry several (#868) and emptying it would
|
|
// take away keys that were never on this keystroke. An extra default —
|
|
// Alt+Enter beside Shift+Enter — follows its action's first chord rather
|
|
// than being one of its own, so its owner is unbound outright, as it
|
|
// always was.
|
|
use crate::ui::keymap::same_chord;
|
|
let displaced: Option<(String, Vec<String>)> = crate::ui::keymap::effective_chords(cx)
|
|
.into_iter()
|
|
.find(|(a, chords)| *a != action && chords.iter().any(|k| same_chord(k, &spec)))
|
|
.map(|(a, chords)| {
|
|
let rest = chords.into_iter().filter(|k| !same_chord(k, &spec));
|
|
(a, rest.collect())
|
|
})
|
|
.or_else(|| {
|
|
crate::ui::keymap::extra_bindings(cx)
|
|
.into_iter()
|
|
.find(|(a, k)| *a != action && same_chord(k, &spec))
|
|
.map(|(a, _)| (a, Vec::new()))
|
|
});
|
|
// A trailing "…" on an action name marks a command that opens
|
|
// something; it is not punctuation, and inside a sentence it reads as
|
|
// the sentence trailing off — "Rename Tab… took the shortcut from".
|
|
let in_prose = |name: &str| name.trim_end_matches('…').to_string();
|
|
let note = displaced.as_ref().map(|(other, _)| {
|
|
t_fmt(
|
|
L10nKey::AppKeybindingDisplacedNote,
|
|
&[
|
|
(
|
|
"action",
|
|
&in_prose(&crate::ui::keymap::action_entry(&action).1),
|
|
),
|
|
(
|
|
"previous",
|
|
&in_prose(&crate::ui::keymap::action_entry(other).1),
|
|
),
|
|
],
|
|
)
|
|
});
|
|
// Both written as lists. Recording a shortcut sets it — the row showed
|
|
// one chord and now shows another — and a bare string in config adds a
|
|
// chord beside the default instead (#868).
|
|
self.update_config(cx, |cfg| {
|
|
use crate::core::config::KeybindingOverride;
|
|
if let Some((other, rest)) = &displaced {
|
|
cfg.keybindings
|
|
.insert(other.clone(), KeybindingOverride::Exact(rest.clone()));
|
|
}
|
|
cfg.keybindings
|
|
.insert(action, KeybindingOverride::Exact(vec![spec]));
|
|
});
|
|
crate::ui::keymap::rebind(cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.rebinding_note = note;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// Takes every chord off an action, and keeps it off.
|
|
///
|
|
/// ⌫ on a row that has recorded nothing used to *reset* it — drop the
|
|
/// override so the action gets its shipped chord back. On a row nobody has
|
|
/// overridden, which is every row the first time it is looked at, that is a
|
|
/// no-op: someone pressing Backspace over Alt+1 to be rid of it watched
|
|
/// Alt+1 sit exactly where it was and read it as the default restoring
|
|
/// itself (#901). Nothing anywhere in the app said "this action should have
|
|
/// no key", though `config.json` has spelled it `[]` since #868.
|
|
///
|
|
/// So ⌫ writes that empty list, and the **Reset** button beside the row —
|
|
/// which appears the moment an action is overridden, this way included — is
|
|
/// the way back to the default.
|
|
pub(crate) fn unbind_keybinding(&mut self, action: String, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| {
|
|
cfg.keybindings.insert(
|
|
action,
|
|
crate::core::config::KeybindingOverride::Exact(Vec::new()),
|
|
);
|
|
});
|
|
crate::ui::keymap::rebind(cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.recording = None;
|
|
s.rebinding_note = None;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| {
|
|
cfg.keybindings.remove(&action);
|
|
});
|
|
crate::ui::keymap::rebind(cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.recording = None;
|
|
s.rebinding_note = None;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn restore_default_keybindings(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Nothing here is recoverable: the overrides are dropped from config
|
|
// and the only record of them was the config.
|
|
if cx.global::<Config>().keybindings.is_empty() {
|
|
return;
|
|
}
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaults),
|
|
Some(t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaultsBody)),
|
|
&crate::ui::confirm_answers(
|
|
t(crate::ui::i18n::L10nKey::SettingsRestoreAllDefaults),
|
|
t(crate::ui::i18n::L10nKey::Cancel),
|
|
),
|
|
cx,
|
|
);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let Ok(0) = answer.await else { return };
|
|
let _ = this.update(cx, |this, cx| {
|
|
this.restore_default_keybindings_confirmed(cx)
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn restore_default_keybindings_confirmed(&mut self, cx: &mut Context<Self>) {
|
|
self.update_config(cx, |cfg| cfg.keybindings.clear());
|
|
crate::ui::keymap::rebind(cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.recording = None;
|
|
s.rebinding_note = None;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_keybinding_preset(&mut self, preset: &str, cx: &mut Context<Self>) {
|
|
let preset = preset.to_string();
|
|
self.update_config(cx, |cfg| cfg.keybinding_preset = preset);
|
|
crate::ui::keymap::rebind(cx);
|
|
if let Some(s) = self.active_settings_mut() {
|
|
s.recording = None;
|
|
s.rebinding_note = None;
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_keybinding_prefix(&mut self, prefix: &str, cx: &mut Context<Self>) {
|
|
let prefix = prefix.to_string();
|
|
self.update_config(cx, |cfg| cfg.prefix = prefix);
|
|
crate::ui::keymap::rebind(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn open_config_file(&self, cx: &Context<Self>) {
|
|
let Some(path) = crate::core::config::config_path("config.json") else {
|
|
return;
|
|
};
|
|
if !path.exists() {
|
|
cx.global::<Config>().save();
|
|
}
|
|
let opener = if cfg!(target_os = "macos") {
|
|
"open"
|
|
} else if cfg!(windows) {
|
|
"explorer"
|
|
} else {
|
|
"xdg-open"
|
|
};
|
|
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
|
|
log::warn!("failed to open {}: {e}", path.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod render_probe {
|
|
use std::cell::Cell;
|
|
|
|
thread_local! {
|
|
static DRAWS: Cell<u64> = const { Cell::new(0) };
|
|
static BUDGET: Cell<Option<u64>> = const { Cell::new(None) };
|
|
}
|
|
|
|
pub(crate) fn record() {
|
|
let n = DRAWS.get() + 1;
|
|
DRAWS.set(n);
|
|
if let Some(budget) = BUDGET.get()
|
|
&& n > budget
|
|
{
|
|
BUDGET.set(None);
|
|
panic!(
|
|
"the window drew more than {budget} frames without input: it never reached \
|
|
render idle (issue #243)"
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn arm(budget: u64) {
|
|
DRAWS.set(0);
|
|
BUDGET.set(Some(budget));
|
|
}
|
|
|
|
pub(crate) fn draws() -> u64 {
|
|
DRAWS.get()
|
|
}
|
|
}
|
|
|
|
impl Tty7App {
|
|
fn render_remote_workspace_strip(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
|
|
if self.tabs.is_empty() {
|
|
return None;
|
|
}
|
|
let status = self.remote_status(cx)?;
|
|
let machine = self.remote_machine_label(cx);
|
|
let message = status.strip_message(&machine)?;
|
|
// While an install runs, the strip is that install's progress: the
|
|
// refusal it is answering is no longer the news, and the button that
|
|
// started it would only start a second one.
|
|
let installing = self.remote_strip_progress(cx);
|
|
let action = installing
|
|
.is_none()
|
|
.then(|| self.remote_strip_action(&status, cx))
|
|
.flatten();
|
|
let theme = cx.theme();
|
|
let message = match installing {
|
|
Some(phase) => format!(
|
|
"{machine} — {}",
|
|
crate::ui::remote_workspace::install_phase_caption(phase)
|
|
),
|
|
None => message,
|
|
};
|
|
let bar = crate::ui::remote_workspace::status_card(cx)
|
|
.occlude()
|
|
.shadow_md()
|
|
.child(
|
|
crate::ui::remote_workspace::status_row()
|
|
.child(gpui_component::Icon::new(gpui_component::IconName::Globe))
|
|
.child(
|
|
crate::ui::remote_workspace::status_message(message)
|
|
.font_weight(gpui::FontWeight::MEDIUM)
|
|
.text_color(theme.foreground),
|
|
)
|
|
.when_some(action, |this, (label, action)| {
|
|
use gpui_component::Sizable as _;
|
|
use gpui_component::button::ButtonVariants as _;
|
|
this.child(
|
|
gpui_component::button::Button::new("remote-status-action")
|
|
.flex_shrink_0()
|
|
.label(label)
|
|
.primary()
|
|
.small()
|
|
.on_click(cx.listener(move |this, _, window, cx| {
|
|
this.run_strip_action(action.clone(), window, cx);
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.when_some(installing, |this, phase| {
|
|
this.child(crate::ui::remote_workspace::install_progress_bar(phase, cx))
|
|
});
|
|
Some(
|
|
div()
|
|
.absolute()
|
|
.top_2()
|
|
.left_0()
|
|
.right_0()
|
|
.flex()
|
|
.justify_center()
|
|
.child(bar)
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
fn render_remote_input_notice(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
|
|
if self.tabs.is_empty() {
|
|
return None;
|
|
}
|
|
let notice = self.remote_status(cx)?.input_notice()?;
|
|
// The pill only. `body_area` anchors it, together with whatever else
|
|
// is floating down there — see `ui::notice`.
|
|
Some(
|
|
crate::ui::notice::pill(cx.theme().warning, cx)
|
|
.child(notice)
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct ForwardRoute {
|
|
pane_id: u64,
|
|
workspace: Option<crate::terminal::PaneWorkspace>,
|
|
}
|
|
|
|
/// What came of asking the far side to put one forward up.
|
|
enum PlaceOutcome {
|
|
Placed,
|
|
/// The far side answered and the rule could not be started — a bind that
|
|
/// collided, a port this process may not have. Another bind port might.
|
|
Rejected(String),
|
|
/// Nobody answered. Nothing was bound, and nothing about the rule is what
|
|
/// went wrong.
|
|
Unreachable(String),
|
|
}
|
|
|
|
/// Who a set of forwards belongs to on the far side.
|
|
///
|
|
/// A workspace's forwards outlive any one of its panes and are shared between
|
|
/// all of them, so "have we already offered to forward :3000" is a question
|
|
/// about the workspace — asking it per pane made switching tabs re-announce
|
|
/// every port the workspace had already forwarded.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub(crate) enum ForwardOwnerKey {
|
|
Pane(u64),
|
|
Workspace(crate::core::session::WorkspaceId),
|
|
}
|
|
|
|
impl ForwardRoute {
|
|
pub(crate) fn new(pane_id: u64, workspace: Option<crate::terminal::PaneWorkspace>) -> Self {
|
|
Self { pane_id, workspace }
|
|
}
|
|
|
|
/// Which side of the daemon's own forward registry this route addresses —
|
|
/// the same split `ForwardOwner` makes there.
|
|
pub(crate) fn owner_key(&self) -> ForwardOwnerKey {
|
|
match &self.workspace {
|
|
Some(ws) => ForwardOwnerKey::Workspace(ws.workspace),
|
|
None => ForwardOwnerKey::Pane(self.pane_id),
|
|
}
|
|
}
|
|
|
|
fn workspace_op(
|
|
&self,
|
|
op: crate::daemon::protocol::WorkspaceOp,
|
|
) -> Option<crate::daemon::protocol::WorkspaceRequest> {
|
|
crate::terminal::RemoteTerminal::workspace_request(
|
|
self.workspace.as_ref()?,
|
|
self.pane_id,
|
|
op,
|
|
)
|
|
}
|
|
|
|
/// The list a forward request answered with, or `None` when it did not
|
|
/// answer at all.
|
|
///
|
|
/// The two are not the same and the panel has to be able to tell them
|
|
/// apart: an empty list is a pane with no forwards left, while a request
|
|
/// that failed says nothing about what the far side still has. Reporting
|
|
/// the second as the first is what blanked the panel whenever the daemon
|
|
/// was briefly unreachable.
|
|
fn forwards(
|
|
reply: anyhow::Result<crate::daemon::protocol::DaemonMsg>,
|
|
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
|
|
match reply {
|
|
Ok(crate::daemon::protocol::DaemonMsg::ForwardList(list)) => Some(list),
|
|
Ok(other) => {
|
|
log::warn!("unexpected reply to a workspace forward request: {other:?}");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
log::warn!("a workspace forward request failed: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn list(&self) -> Vec<crate::daemon::protocol::ManagedForward> {
|
|
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::ListForwards)
|
|
else {
|
|
return crate::terminal::RemoteTerminal::list_forwards(self.pane_id);
|
|
};
|
|
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)).unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn add(
|
|
&self,
|
|
rule: crate::daemon::protocol::SshForwardRule,
|
|
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
|
|
let Some(req) = self
|
|
.workspace_op(crate::daemon::protocol::WorkspaceOp::AddForward { rule: rule.clone() })
|
|
else {
|
|
return crate::terminal::RemoteTerminal::add_forward(self.pane_id, rule);
|
|
};
|
|
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req))
|
|
}
|
|
|
|
pub(crate) fn teardown(&self) -> Vec<crate::daemon::protocol::ManagedForward> {
|
|
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::TeardownForwards)
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)).unwrap_or_default()
|
|
}
|
|
|
|
/// The local port that reaches `remote_host:remote_port` over this route,
|
|
/// building the forward if there is not one yet.
|
|
///
|
|
/// The far side keeps one automatic forward per endpoint and hands the
|
|
/// same port back on the next ask, so callers may treat this as "what is
|
|
/// the address here" rather than as an action with a cost — which is what
|
|
/// lets the Ports list call it on a click and the watcher call it on a
|
|
/// port it has only just noticed.
|
|
pub(crate) fn ensure_loopback(
|
|
&self,
|
|
remote_host: &str,
|
|
remote_port: u16,
|
|
) -> anyhow::Result<crate::daemon::protocol::LoopbackForward> {
|
|
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::EnsureLoopback {
|
|
remote_host: remote_host.to_string(),
|
|
remote_port,
|
|
}) else {
|
|
return crate::terminal::RemoteTerminal::ensure_loopback_forward(
|
|
self.pane_id,
|
|
remote_host,
|
|
remote_port,
|
|
);
|
|
};
|
|
match crate::terminal::RemoteTerminal::on_workspace(req)? {
|
|
crate::daemon::protocol::DaemonMsg::LoopbackForward(f) => Ok(f),
|
|
other => Err(anyhow::anyhow!(
|
|
"unexpected reply to EnsureLoopback: {other:?}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn remove(
|
|
&self,
|
|
forward_id: u64,
|
|
) -> Option<Vec<crate::daemon::protocol::ManagedForward>> {
|
|
let Some(req) =
|
|
self.workspace_op(crate::daemon::protocol::WorkspaceOp::RemoveForward { forward_id })
|
|
else {
|
|
return crate::terminal::RemoteTerminal::remove_forward(self.pane_id, forward_id);
|
|
};
|
|
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req))
|
|
}
|
|
}
|
|
|
|
impl Render for Tty7App {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
#[cfg(test)]
|
|
render_probe::record();
|
|
let prof = crate::ui::perf::enabled().then(std::time::Instant::now);
|
|
// Every window's root is this view, so setting the rem here is what
|
|
// makes `ui_font_size` reach the whole interface — the rem ladder
|
|
// (`text_sm`, `text_xs`, `rems(..)`) resolves against it, and the
|
|
// terminal grid, sized in absolute px from `font_size`, does not move.
|
|
window.set_rem_size(px(cx.global::<Config>().ui_font_size));
|
|
self.claim_pending_tab(window, cx);
|
|
self.touch_active_tab();
|
|
self.declare_displayed_panes(cx);
|
|
self.scm_sync_watchers(window, cx);
|
|
// Keeps looking for new listening ports on the pane in front, panel
|
|
// open or not — a port that appears while the panel is shut is exactly
|
|
// the one worth forwarding unasked.
|
|
self.sync_port_watch(window, cx);
|
|
if cx.has_active_drag() {
|
|
crate::ui::reorder::clear_pending(&self.reorder);
|
|
crate::ui::pane_drag::clear_landing(&self.pane_drag);
|
|
} else {
|
|
// Taken first either way: this is what ends the drag, and the merge
|
|
// below must not find the tab it just moved still in the air.
|
|
let landed = crate::ui::reorder::take_landed(&self.reorder);
|
|
if let Some((tab, zone)) = self.tab_merge.take() {
|
|
self.merge_tab(tab, zone, window, cx);
|
|
} else if let Some((tab, key)) = landed.regroup {
|
|
// A drop into another group outranks the reordering the drag
|
|
// did on its way out of the one it came from. The pointer
|
|
// left that group; the shuffle it caused before leaving is
|
|
// not what was being asked for.
|
|
self.regroup_tab(tab, key, cx);
|
|
} else if let Some(order) = landed.order {
|
|
self.apply_tab_order(&order, cx);
|
|
}
|
|
// Also what ends the pane drag, so it is taken whichever of the two
|
|
// readings the last frame left behind.
|
|
let landing = crate::ui::pane_drag::take_landing(&self.pane_drag);
|
|
if let Some((pane, at)) = self.pane_detach.take() {
|
|
self.detach_pane(pane, at, window, cx);
|
|
} else if let Some((from, zone)) = landing {
|
|
self.drop_pane(from, zone, window, cx);
|
|
}
|
|
}
|
|
// Windows has no closed-hand cursor and gpui answers `ClosedHand` with
|
|
// the plain arrow there, which would drop the grip's pointing hand the
|
|
// instant the drag it advertised began.
|
|
let held = if cfg!(target_os = "windows") {
|
|
gpui::CursorStyle::PointingHand
|
|
} else {
|
|
gpui::CursorStyle::ClosedHand
|
|
};
|
|
if (self.reorder.borrow().is_some() || self.pane_drag.borrow().is_some())
|
|
&& cx.active_drag_cursor_style() != Some(held)
|
|
{
|
|
cx.set_active_drag_cursor_style(held, window);
|
|
}
|
|
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
|
|
&& !self.tabs.is_empty();
|
|
// Asked through the predicate the right panel sizes itself against —
|
|
// two spellings of "is the rail up" is one more than the layout can
|
|
// afford to have disagree.
|
|
let rail = self.sidebar_open(cx);
|
|
// Both read before the strip and the sidebar are built: a tab held out
|
|
// over the layout suspends the reorder, which is what those two ask
|
|
// what to draw, and a pane held over *them* is measured against where
|
|
// they put their tabs last frame — which is what they are about to
|
|
// blank and write again.
|
|
let tab_landing = self.tab_landing(window, cx);
|
|
let detach_caret = self.detach_caret(window, cx);
|
|
let strip = self.tab_strip(!vertical, window, cx);
|
|
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
|
|
let ssh_status = self
|
|
.tabs
|
|
.get(self.active)
|
|
.and_then(|t| t.pane.focused_or_first(window, cx))
|
|
.and_then(|leaf| self.render_ssh_status_strip(&leaf, cx));
|
|
let body = match self.tabs.get(self.active) {
|
|
None => self.render_home(cx).into_any_element(),
|
|
Some(active_tab) => {
|
|
let maximized = self.maximized.as_ref().filter(|leaf| {
|
|
active_tab
|
|
.pane
|
|
.leaves()
|
|
.iter()
|
|
.any(|l| l.entity_id() == leaf.entity_id())
|
|
});
|
|
match maximized {
|
|
Some(leaf) => div()
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.child(leaf.clone())
|
|
.into_any_element(),
|
|
None => {
|
|
let several = active_tab.pane.leaves().len() > 1;
|
|
let chrome = crate::ui::pane::PaneChrome {
|
|
dim_inactive: several && cx.global::<Config>().dim_inactive_panes,
|
|
rearrangeable: several,
|
|
hovered: self.pane_hover.clone(),
|
|
lifted: crate::ui::pane_drag::lifted(&self.pane_drag),
|
|
drag: self.pane_drag.clone(),
|
|
};
|
|
active_tab.pane.render(&chrome, window, cx)
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// No window buttons in fullscreen, where they cannot work: the window
|
|
// has no caption for the platform to hit-test, so they would draw,
|
|
// light up under the pointer and do nothing when clicked. `TitleBar`
|
|
// always draws them, so the strip goes into a plain row of the same
|
|
// geometry instead — the row itself stays, since it holds the tabs,
|
|
// the chrome tiles and the docked document's header.
|
|
let title_bar =
|
|
if window_controls_drawn(window.is_fullscreen()) || cfg!(target_os = "macos") {
|
|
TitleBar::new()
|
|
.h(px(TITLE_BAR_HEIGHT))
|
|
.bg(cx.theme().transparent)
|
|
.border_color(cx.theme().transparent)
|
|
.child(strip)
|
|
.into_any_element()
|
|
} else {
|
|
div()
|
|
.flex_shrink_0()
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.h(px(TITLE_BAR_HEIGHT))
|
|
.pl(px(TITLE_BAR_LEAD))
|
|
.border_b_1()
|
|
.border_color(cx.theme().transparent)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.h_full()
|
|
.flex_1()
|
|
.child(strip),
|
|
)
|
|
.into_any_element()
|
|
};
|
|
let body_area = div()
|
|
.flex_1()
|
|
.relative()
|
|
.overflow_hidden()
|
|
.child(
|
|
gpui::canvas(
|
|
{
|
|
let area = self.pane_area.clone();
|
|
move |bounds, _window, _cx| area.set(Some(bounds))
|
|
},
|
|
|_, _, _, _| {},
|
|
)
|
|
.absolute()
|
|
.inset_0(),
|
|
)
|
|
.child(body)
|
|
.when_some(self.pane_landing(window, cx), |this, el| this.child(el))
|
|
.when_some(tab_landing, |this, el| this.child(el))
|
|
.when_some(self.render_remote_workspace_strip(cx), |this, el| {
|
|
this.child(el)
|
|
})
|
|
// Both of these used to anchor themselves at `bottom_4` and centre
|
|
// themselves, as siblings here — so a remote workspace whose ssh
|
|
// link had also dropped drew them one on top of the other. One
|
|
// anchor now, and it stacks. The ssh strip goes last because it is
|
|
// the one carrying buttons.
|
|
.when_some(
|
|
crate::ui::notice::anchor(
|
|
[self.render_remote_input_notice(cx), ssh_status]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect(),
|
|
),
|
|
|this, el| this.child(el),
|
|
);
|
|
|
|
// One decision for the whole document surface. Docked, exactly one of
|
|
// the two surfaces is drawn — a column has one child, and two `flex_1`
|
|
// siblings would split it and fight — so `overlay_top` stops ordering a
|
|
// pair and starts choosing between them. Filling, nothing changes: both
|
|
// are rendered, ordered by `overlay_top`, and the front one wins on
|
|
// paint order as it always has.
|
|
let document_dock_px = self.document_dock_px(window, cx);
|
|
// Where the docked header sits. Everywhere but macOS the title bar
|
|
// spans the workspace and leaves the strip above the column empty, so
|
|
// the header goes up into it; on macOS the column already reaches the
|
|
// top of the window and its own first row lands there.
|
|
let document_chrome = if cfg!(target_os = "macos") {
|
|
crate::ui::document_column::DocumentChrome::Dock
|
|
} else {
|
|
crate::ui::document_column::DocumentChrome::DockHoisted
|
|
};
|
|
let document_header = document_dock_px
|
|
.is_some()
|
|
.then(|| self.render_document_header(document_chrome, window, cx))
|
|
.flatten();
|
|
let (overlays, document_column) = match document_dock_px {
|
|
Some(w) => (
|
|
Vec::new(),
|
|
self.render_document_column(w, document_chrome, window, cx),
|
|
),
|
|
None => {
|
|
let diff_overlay = self.render_diff_overlay(
|
|
crate::ui::document_column::DocumentChrome::Fill,
|
|
window,
|
|
cx,
|
|
);
|
|
let code_overlay = self.render_code_overlay(
|
|
crate::ui::document_column::DocumentChrome::Fill,
|
|
window,
|
|
cx,
|
|
);
|
|
let mut pair = vec![
|
|
(OverlayTop::Diff, diff_overlay),
|
|
(OverlayTop::Code, code_overlay),
|
|
];
|
|
if self
|
|
.tabs
|
|
.get(self.active)
|
|
.is_some_and(|t| t.overlay_top == OverlayTop::Diff)
|
|
{
|
|
pair.reverse();
|
|
}
|
|
(
|
|
pair.into_iter()
|
|
.filter_map(|(_, el)| el)
|
|
.collect::<Vec<gpui::AnyElement>>(),
|
|
None,
|
|
)
|
|
}
|
|
};
|
|
let document_px = document_column
|
|
.as_ref()
|
|
.map_or(0., |_| document_dock_px.unwrap_or_default());
|
|
|
|
let right_panel = self.render_right_panel(window, cx);
|
|
// A docked document takes the same fork the right panel does: on
|
|
// Windows and Linux the window controls live at the right end of the
|
|
// title bar, so the bar has to span the workspace rather than sit
|
|
// inside the terminal column with a column drawn to the right of it.
|
|
let panel_below_title_bar =
|
|
(right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos");
|
|
let (column_title_bar, spanning_title_bar) = if panel_below_title_bar {
|
|
(None, Some(title_bar))
|
|
} else {
|
|
(Some(title_bar), None)
|
|
};
|
|
let (column_overlays, hoisted_overlays) = if panel_below_title_bar {
|
|
(Vec::new(), overlays)
|
|
} else {
|
|
(overlays, Vec::new())
|
|
};
|
|
let panel_px = if right_panel.is_some() {
|
|
self.right_panel_px(window, cx)
|
|
} else {
|
|
0.
|
|
};
|
|
let terminal_column = div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.flex()
|
|
.flex_col()
|
|
.relative()
|
|
.when_some(column_title_bar, |this, bar| this.child(bar))
|
|
.child(body_area)
|
|
.children(column_overlays);
|
|
let panel_row = div()
|
|
.flex_1()
|
|
.min_h_0()
|
|
.min_w_0()
|
|
.flex()
|
|
.flex_row()
|
|
.child(terminal_column)
|
|
.when_some(document_column, |this, column| this.child(column))
|
|
.when_some(right_panel, |this, panel| this.child(panel));
|
|
let main_layout = div()
|
|
.flex_1()
|
|
.min_h_0()
|
|
.w_full()
|
|
.flex()
|
|
.flex_row()
|
|
.when_some(sidebar, |this, sidebar| this.child(sidebar))
|
|
.child(match spanning_title_bar {
|
|
Some(bar) => div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.flex()
|
|
.flex_col()
|
|
.relative()
|
|
.child(
|
|
div()
|
|
.relative()
|
|
.flex_none()
|
|
// One patch per column below, rather than one for
|
|
// both: each carries the left border its own column
|
|
// carries, so the rule between the document and the
|
|
// detail panel runs the full height of the window
|
|
// instead of stopping at the title bar.
|
|
.when(panel_px > 0., |this| {
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.right_0()
|
|
.w(px(panel_px))
|
|
.bg(crate::ui::theme::workspace_surface_color(cx))
|
|
.border_l_1()
|
|
.border_color(cx.theme().sidebar_border),
|
|
)
|
|
})
|
|
.when(document_px > 0., |this| {
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.right(px(panel_px))
|
|
.w(px(document_px))
|
|
.bg(crate::ui::theme::workspace_surface_color(cx))
|
|
.border_l_1()
|
|
.border_color(cx.theme().sidebar_border),
|
|
)
|
|
})
|
|
.child(bar)
|
|
// The document's header, in the strip the spanning
|
|
// title bar leaves empty above its column. Drawn
|
|
// after the bar so it sits over the tab strip's
|
|
// slack — and stopping short of the trailing
|
|
// chrome, which is only in the way when the detail
|
|
// panel is closed and this column is the one at the
|
|
// window's right edge.
|
|
.when_some(document_header, |this, header| {
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.h(px(TITLE_BAR_HEIGHT))
|
|
.right(px(panel_px))
|
|
.w(px(document_px))
|
|
.when(panel_px <= 0., |d| {
|
|
d.pr(px(crate::ui::tab_strip::trailing_chrome_w(
|
|
window.is_fullscreen(),
|
|
)))
|
|
})
|
|
.child(header),
|
|
)
|
|
}),
|
|
)
|
|
.child(panel_row)
|
|
.children(hoisted_overlays.into_iter().map(|overlay| {
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.bottom_0()
|
|
.right(px(panel_px))
|
|
.child(overlay)
|
|
}))
|
|
.into_any_element(),
|
|
None => panel_row.into_any_element(),
|
|
})
|
|
.into_any_element();
|
|
|
|
let window_bg = crate::ui::theme::workspace_background(cx);
|
|
let bg_image = window_background_image_layer(cx);
|
|
let settings_bg = crate::ui::theme::overlay_background(cx);
|
|
|
|
let settings_overlay = self.settings.is_some().then(|| {
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.occlude()
|
|
// Opaque on purpose: the settings panel must never let the
|
|
// workspace translucency (window opacity / backdrop material)
|
|
// show through, even at window edges during a resize. The
|
|
// preset's gradient fill is preserved, just with alpha 1;
|
|
// `render_settings` repaints the theme image over it.
|
|
.bg(settings_bg)
|
|
.child(self.render_settings(window, cx))
|
|
});
|
|
|
|
let root =
|
|
div()
|
|
.id("tty7-root")
|
|
.size_full()
|
|
.flex()
|
|
.flex_col()
|
|
.bg(window_bg)
|
|
.text_color(cx.theme().foreground)
|
|
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
|
|
.on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx)))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace1, window, cx| {
|
|
this.select_workspace_slot(0, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace2, window, cx| {
|
|
this.select_workspace_slot(1, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace3, window, cx| {
|
|
this.select_workspace_slot(2, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace4, window, cx| {
|
|
this.select_workspace_slot(3, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace5, window, cx| {
|
|
this.select_workspace_slot(4, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace6, window, cx| {
|
|
this.select_workspace_slot(5, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace7, window, cx| {
|
|
this.select_workspace_slot(6, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace8, window, cx| {
|
|
this.select_workspace_slot(7, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectWorkspace9, window, cx| {
|
|
this.select_workspace_slot(8, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &RenameWorkspace, window, cx| {
|
|
this.start_workspace_rename(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleSwitcher, window, cx| {
|
|
this.toggle_switcher(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &StopWorkspace, window, cx| {
|
|
let id = this.workspace;
|
|
this.stop_workspace(id, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &DeleteWorkspace, window, cx| {
|
|
let id = this.workspace;
|
|
this.delete_workspace(id, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &NewWorkspace, window, cx| {
|
|
this.open_workspace_form(window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &NewWindow, _window, cx| {
|
|
this.new_window(cx);
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &CloseWindow, window, cx| this.close_window(window, cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| {
|
|
if !this.editor_close_active_if_focused(window, cx) {
|
|
this.close_pane(window, cx)
|
|
}
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SplitRight, window, cx| {
|
|
this.split(Axis::Horizontal, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SplitDown, window, cx| {
|
|
this.split(Axis::Vertical, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusNextPane, window, cx| {
|
|
this.cycle_pane(true, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPrevPane, window, cx| {
|
|
this.cycle_pane(false, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPaneLeft, window, cx| {
|
|
this.focus_pane_dir(Dir::Left, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPaneRight, window, cx| {
|
|
this.focus_pane_dir(Dir::Right, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPaneUp, window, cx| {
|
|
this.focus_pane_dir(Dir::Up, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| {
|
|
this.focus_pane_dir(Dir::Down, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ResizePaneLeft, window, cx| {
|
|
this.resize_pane(Dir::Left, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ResizePaneRight, window, cx| {
|
|
this.resize_pane(Dir::Right, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ResizePaneUp, window, cx| {
|
|
this.resize_pane(Dir::Up, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ResizePaneDown, window, cx| {
|
|
this.resize_pane(Dir::Down, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SwapPaneNext, window, cx| {
|
|
this.swap_pane(true, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SwapPanePrev, window, cx| {
|
|
this.swap_pane(false, window, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &NextTab, window, cx| this.tab_switch(true, window, cx)),
|
|
)
|
|
.on_action(
|
|
cx.listener(|this, _: &PrevTab, window, cx| this.tab_switch(false, window, cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &SelectNextTab, window, cx| {
|
|
this.cycle_tab(true, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectPrevTab, window, cx| {
|
|
this.cycle_tab(false, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab1, window, cx| {
|
|
this.activate_visual(0, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab2, window, cx| {
|
|
this.activate_visual(1, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab3, window, cx| {
|
|
this.activate_visual(2, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab4, window, cx| {
|
|
this.activate_visual(3, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab5, window, cx| {
|
|
this.activate_visual(4, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab6, window, cx| {
|
|
this.activate_visual(5, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab7, window, cx| {
|
|
this.activate_visual(6, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab8, window, cx| {
|
|
this.activate_visual(7, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ActivateTab9, window, cx| {
|
|
this.activate_visual(8, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &IncreaseFontSize, _window, cx| {
|
|
this.change_font_size(FONT_SIZE_STEP, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &DecreaseFontSize, _window, cx| {
|
|
this.change_font_size(-FONT_SIZE_STEP, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &ResetFontSize, _window, cx| this.reset_font_size(cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &TogglePalette, window, cx| {
|
|
this.toggle_palette(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ReopenClosedTab, window, cx| {
|
|
this.reopen_closed_tab(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleMaximizePane, window, cx| {
|
|
this.toggle_maximize(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleFullscreen, window, cx| {
|
|
this.toggle_fullscreen(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleTabSidebar, _window, cx| {
|
|
this.toggle_tab_sidebar(cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &ToggleLeftPanel, _window, cx| {
|
|
this.toggle_left_panel(cx)
|
|
}),
|
|
)
|
|
.on_action(cx.listener(|this, _: &ToggleRightPanel, _window, cx| {
|
|
this.toggle_right_panel(cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ShowRightPanelInfo, _window, cx| {
|
|
this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
|
|
this.set_right_panel_tab(crate::core::config::RightPanelTab::Scm, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
|
|
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &ScmToggleGraph, _window, cx| this.scm_toggle_graph(cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| {
|
|
this.toggle_diff_view_mode(cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| {
|
|
this.toggle_document_fill(cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &DocumentWidthThird, _window, cx| {
|
|
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &DocumentWidthHalf, _window, cx| {
|
|
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &DocumentWidthTwoThirds, _window, cx| {
|
|
this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
|
|
}),
|
|
)
|
|
.on_action(cx.listener(|this, _: &ScmCommit, window, cx| {
|
|
this.run_scm_action(ScmIntent::Commit, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmCommitAmend, window, cx| {
|
|
this.run_scm_action(ScmIntent::CommitAmend, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmStageAll, window, cx| {
|
|
this.run_scm_action(ScmIntent::StageAll, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmUnstageAll, window, cx| {
|
|
this.run_scm_action(ScmIntent::UnstageAll, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmDiscardAll, window, cx| {
|
|
this.run_scm_action(ScmIntent::DiscardAll, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmRefresh, window, cx| {
|
|
this.run_scm_action(ScmIntent::Refresh, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmSync, window, cx| {
|
|
this.run_scm_action(ScmIntent::Sync, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmPush, window, cx| {
|
|
this.run_scm_action(ScmIntent::Push, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmPull, window, cx| {
|
|
this.run_scm_action(ScmIntent::Pull, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmFetch, window, cx| {
|
|
this.run_scm_action(ScmIntent::Fetch, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmCheckoutBranch, window, cx| {
|
|
this.run_scm_action(ScmIntent::CheckoutBranch, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ScmCreateBranch, window, cx| {
|
|
this.run_scm_action(ScmIntent::CreateBranch, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
|
|
this.toggle_settings(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &RestartDaemon, window, cx| {
|
|
this.restart_window_daemon(window, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &ShowSshForwards, window, cx| {
|
|
this.show_ssh_forwards(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleCodePanel, window, cx| {
|
|
this.toggle_code_panel(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &EditorSave, window, cx| {
|
|
if !this.editor_has_focus(window, cx) {
|
|
cx.propagate();
|
|
return;
|
|
}
|
|
this.editor_save_active(window, cx)
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)),
|
|
)
|
|
.on_action(cx.listener(|this, _: &OpenSshProfiles, window, cx| {
|
|
this.open_settings_section(SettingsSection::Ssh, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &RestartSshSession, window, cx| {
|
|
this.restart_ssh_session(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &RenameTab, window, cx| {
|
|
this.start_rename(this.active, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &NewWorktreeTab, window, cx| {
|
|
this.new_worktree_tab(this.active, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CloseOtherTabs, window, cx| {
|
|
this.close_other_tabs(this.active, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CloseTabsToTheRight, window, cx| {
|
|
this.close_tabs_right_of(this.active, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CopyWorkingDirectory, window, cx| {
|
|
this.copy_active_cwd(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
|
|
this.mark_tab_unread(this.active, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| {
|
|
this.fork_active_pane_session(ForkPlacement::NewTab, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ForkAgentSessionRight, window, cx| {
|
|
this.fork_focused_pane_session(Axis::Horizontal, false, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ForkAgentSessionLeft, window, cx| {
|
|
this.fork_focused_pane_session(Axis::Horizontal, true, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ForkAgentSessionDown, window, cx| {
|
|
this.fork_focused_pane_session(Axis::Vertical, false, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ForkAgentSessionUp, window, cx| {
|
|
this.fork_focused_pane_session(Axis::Vertical, true, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| {
|
|
this.copy_agent_session_id(this.active, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| {
|
|
this.open_settings_section(SettingsSection::Keybindings, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &About, window, cx| {
|
|
this.open_settings_section(SettingsSection::About, window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CheckForUpdates, window, cx| {
|
|
this.check_for_updates_now(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|_, _: &HideApp, _window, cx| cx.hide()))
|
|
.on_action(cx.listener(|_, _: &HideOthers, _window, cx| cx.hide_other_apps()))
|
|
.on_action(cx.listener(|_, _: &ShowAll, _window, cx| cx.unhide_other_apps()))
|
|
.on_action(
|
|
cx.listener(|_, _: &MinimizeWindow, window, _cx| window.minimize_window()),
|
|
)
|
|
.on_action(cx.listener(|_, _: &ZoomWindow, window, _cx| window.zoom_window()))
|
|
.on_action(
|
|
cx.listener(|_, _: &OpenDocumentation, _window, cx| cx.open_url(DOCS_URL)),
|
|
)
|
|
.on_action(cx.listener(|_, _: &OpenDiscord, _window, cx| cx.open_url(DISCORD_URL)))
|
|
.on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL)))
|
|
.children(bg_image)
|
|
.child(main_layout)
|
|
// Window-level because the strip lives in the title bar and the
|
|
// sidebar down the side: the caret between two tabs is in
|
|
// neither of the boxes the rest of the drag feedback is drawn
|
|
// in.
|
|
.when_some(detach_caret, |this, caret| this.child(caret))
|
|
.when_some(settings_overlay, |this, overlay| this.child(overlay))
|
|
// Window-level, like the switcher and the palette: the prompt
|
|
// blocks the whole app, so its scrim has to reach the title bar
|
|
// and the side panels too. Parented to `body_area` it was
|
|
// clipped to the terminal area, which read as "only the
|
|
// terminal is busy".
|
|
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
|
|
this.child(el)
|
|
})
|
|
// Same reason, and the ssh prompt has more claim to it than any
|
|
// of them: nothing in the window can proceed until the password
|
|
// is answered, so the scrim has to cover the whole window and
|
|
// not stop at the terminal area.
|
|
.when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| {
|
|
this.child(el)
|
|
})
|
|
.children(self.render_switcher(window, cx))
|
|
.when_some(self.palette.clone(), |this, palette| this.child(palette))
|
|
.children(gpui_component::Root::render_notification_layer(window, cx));
|
|
|
|
if let Some(start) = prof {
|
|
crate::ui::perf::record("window", start.elapsed());
|
|
}
|
|
root
|
|
}
|
|
}
|
|
|
|
/// Orders tab indices most-recently-used first from their `last_used` stamps.
|
|
/// A zero stamp means the tab was never activated, and those keep strip order
|
|
/// at the back. `active` leads regardless — its own stamp only lands on the
|
|
/// next frame.
|
|
/// The tab after (or before) `active` in `order`, wrapping round. `None`
|
|
/// when there is nowhere else to go; a tab missing from `order` starts from
|
|
/// its end, so the step still lands on a tab the user can see.
|
|
fn step_in_order(order: &[usize], active: usize, forward: bool) -> Option<usize> {
|
|
let n = order.len();
|
|
if n < 2 {
|
|
return None;
|
|
}
|
|
let pos = order.iter().position(|&i| i == active);
|
|
let next = match (pos, forward) {
|
|
(Some(p), true) => (p + 1) % n,
|
|
(Some(p), false) => (p + n - 1) % n,
|
|
(None, true) => 0,
|
|
(None, false) => n - 1,
|
|
};
|
|
Some(order[next]).filter(|&i| i != active)
|
|
}
|
|
|
|
fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
|
|
let mut order: Vec<usize> = (0..stamps.len()).collect();
|
|
order.sort_by_key(|&i| (stamps[i] == 0, std::cmp::Reverse(stamps[i]), i));
|
|
if let Some(pos) = order.iter().position(|&i| i == active) {
|
|
let lead = order.remove(pos);
|
|
order.insert(0, lead);
|
|
}
|
|
order
|
|
}
|
|
|
|
fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
|
|
SessionTab {
|
|
name: tab.name.clone(),
|
|
pane: pane_to_session(&tab.pane, cx),
|
|
sidebar_group: tab.sidebar_group.borrow().clone(),
|
|
tree_id: None,
|
|
}
|
|
}
|
|
|
|
fn agent_resume_command(
|
|
agent: &Option<crate::core::cli_agent::CLIAgent>,
|
|
session_id: Option<&str>,
|
|
launch_argv: Option<&[String]>,
|
|
cx: &App,
|
|
) -> Option<String> {
|
|
if !cx.global::<Config>().restore_agent_sessions {
|
|
return None;
|
|
}
|
|
let agent = agent.as_ref()?;
|
|
let Some(session_id) = session_id else {
|
|
log::info!(
|
|
"{}'s pane had no captured session id; it comes back as a plain shell",
|
|
agent.display_name()
|
|
);
|
|
return None;
|
|
};
|
|
agent.resume_command(session_id, launch_argv)
|
|
}
|
|
|
|
fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
|
match pane {
|
|
Pane::Leaf(PaneSlot::Connecting(pending)) => {
|
|
let spawn = &pending.read(cx).spawn;
|
|
SessionPane::Leaf {
|
|
cwd: spawn.working_directory.clone(),
|
|
pane_id: spawn.restore_pane,
|
|
shell: spawn.shell.clone(),
|
|
ssh_spec: None,
|
|
agent: spawn.agent,
|
|
agent_session_id: spawn.agent_session_id.clone(),
|
|
agent_launch_argv: spawn.agent_launch_argv.clone(),
|
|
}
|
|
}
|
|
Pane::Leaf(PaneSlot::Ready(view)) => {
|
|
let view = view.read(cx);
|
|
SessionPane::Leaf {
|
|
cwd: view.spawnable_cwd(),
|
|
pane_id: Some(view.pane_id),
|
|
// `None` for a pane this window attached to rather than
|
|
// spawned: it never knew what was on the other end. The tree
|
|
// does — the daemon records it — and that is what a restore
|
|
// reads, so the gap here costs nothing it can see.
|
|
shell: view.shell_spec(),
|
|
ssh_spec: view.ssh_spec(),
|
|
agent: view.agent(),
|
|
agent_session_id: view.agent_session().and_then(|s| s.session_id),
|
|
agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv),
|
|
}
|
|
}
|
|
Pane::Split {
|
|
axis, a, b, ratio, ..
|
|
} => SessionPane::Split {
|
|
axis: match axis {
|
|
Axis::Horizontal => SessionAxis::Horizontal,
|
|
Axis::Vertical => SessionAxis::Vertical,
|
|
},
|
|
ratio: ratio.get(),
|
|
a: Box::new(pane_to_session(a, cx)),
|
|
b: Box::new(pane_to_session(b, cx)),
|
|
},
|
|
Pane::Empty => SessionPane::Leaf {
|
|
cwd: None,
|
|
pane_id: None,
|
|
shell: None,
|
|
ssh_spec: None,
|
|
agent: None,
|
|
agent_session_id: None,
|
|
agent_launch_argv: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// The daemon's account of which panes are alive, or `None` when it could not
|
|
/// be asked at all.
|
|
///
|
|
/// The distinction is the point: a pane absent from a *successful* listing is
|
|
/// genuinely gone and may be respawned, while a failed `List` says nothing
|
|
/// about any pane. Flattening the failure into an empty map made one transient
|
|
/// RPC error read as "every pane is dead", and the restore then spawned fresh
|
|
/// shells over all of them — the same destruction-by-inference this file's
|
|
/// restore path is built to avoid.
|
|
pub(crate) fn alive_panes_on(
|
|
route: &crate::terminal::PaneRoute,
|
|
) -> Option<std::collections::HashMap<u64, Option<String>>> {
|
|
if !matches!(route, crate::terminal::PaneRoute::Local) {
|
|
return Some(std::collections::HashMap::new());
|
|
}
|
|
match crate::terminal::RemoteTerminal::try_list_panes_on(route) {
|
|
Ok(list) => Some(
|
|
list.into_iter()
|
|
.filter(|p| p.alive)
|
|
.map(|p| (p.pane_id, p.owner))
|
|
.collect(),
|
|
),
|
|
Err(e) => {
|
|
log::warn!("could not list panes ({e}); leaving each attach to decide");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether this window may stand on `id` — as the pane it attaches to, or as
|
|
/// the dead predecessor whose screen a fresh pane opens showing.
|
|
///
|
|
/// Liveness deliberately does not come into it, and that is the whole point.
|
|
/// A pane missing from the listing is usually one whose daemon has just
|
|
/// restarted, which is exactly when its stored screen is worth asking for.
|
|
/// Ruling the id out there threw away the only thing that could ask: the
|
|
/// window spawned a pane that had never heard of a predecessor, so no attach
|
|
/// was tried, no restore was requested, and the screen the daemon still had on
|
|
/// disk was swept a tick later, unread.
|
|
///
|
|
/// There used to be a second predicate here that also required the id to be
|
|
/// listed, and the attach site consulted it. Nothing does now: the attach is
|
|
/// simply tried, and a pane that really is gone fails it and falls through to
|
|
/// the fresh spawn — the same outcome the listing was consulted to predict,
|
|
/// reached by asking the daemon instead of guessing ahead of it.
|
|
///
|
|
/// Ownership does come into it. Another workspace's pane is not this window's
|
|
/// to attach to, and its screen is not this window's to show.
|
|
fn pane_free_for(
|
|
alive: Option<&std::collections::HashMap<u64, Option<String>>>,
|
|
id: u64,
|
|
owner: crate::core::session::WorkspaceId,
|
|
) -> bool {
|
|
let Some(alive) = alive else {
|
|
// No listing to consult. Attaching is the safe guess in both
|
|
// directions: if the daemon is really unreachable the attach fails and
|
|
// the pane falls to the fresh-spawn path anyway, while spawning fresh
|
|
// on a hunch destroys a session that was merely hard to reach.
|
|
return true;
|
|
};
|
|
match alive.get(&id) {
|
|
None => true,
|
|
Some(None) => true,
|
|
Some(Some(recorded)) => {
|
|
// Only a workspace id is a claim. Anything else is a client
|
|
// stamping its own name — `tty7` before this release wrote a
|
|
// literal "tty7-cli" — and refusing on it strands every pane the
|
|
// CLI ever made, respawning over a live shell the tree just told
|
|
// us belongs here.
|
|
let Ok(recorded) = recorded.parse::<crate::core::session::WorkspaceId>() else {
|
|
return true;
|
|
};
|
|
let ours = recorded == owner;
|
|
if !ours {
|
|
log::warn!(
|
|
"restore: pane {id} is owned by workspace {recorded}, not {owner}; \
|
|
spawning fresh instead of attaching to it"
|
|
);
|
|
}
|
|
ours
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tabs_from_session(
|
|
workspace: Option<&crate::terminal::PaneWorkspace>,
|
|
owner: WorkspaceId,
|
|
session: Option<Session>,
|
|
font_size: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) -> (Vec<Tab>, usize, usize) {
|
|
let Some(session) = session.filter(|s| !s.tabs.is_empty()) else {
|
|
return (Vec::new(), 0, 0);
|
|
};
|
|
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace));
|
|
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
|
|
let mut dropped = 0usize;
|
|
for st in &session.tabs {
|
|
let Some(pane) = session_to_pane(
|
|
workspace,
|
|
owner,
|
|
&st.pane,
|
|
alive.as_ref(),
|
|
font_size,
|
|
window,
|
|
cx,
|
|
) else {
|
|
// The layout coming back is the product's headline claim, so a tab
|
|
// that quietly does not is worth a sentence rather than a log line.
|
|
log::error!("dropping a restored tab: no pane in it could be started");
|
|
dropped += 1;
|
|
continue;
|
|
};
|
|
tabs.push(Tab {
|
|
pane,
|
|
name: st.name.clone(),
|
|
last_focused: None,
|
|
zoomed: None,
|
|
diff_overlay: None,
|
|
code: None,
|
|
overlay_top: OverlayTop::default(),
|
|
document_layout: None,
|
|
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
|
|
tree_id: std::cell::Cell::new(
|
|
st.tree_id
|
|
.unwrap_or_else(tty7_core::core::machine::TabId::new),
|
|
),
|
|
last_used: std::cell::Cell::new(0),
|
|
focus_origin: Default::default(),
|
|
});
|
|
}
|
|
let active = session.active.min(tabs.len().saturating_sub(1));
|
|
(tabs, active, dropped)
|
|
}
|
|
|
|
fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool {
|
|
!(window_is_remote && leaf_is_native_ssh)
|
|
}
|
|
|
|
fn session_to_pane(
|
|
workspace: Option<&crate::terminal::PaneWorkspace>,
|
|
owner: WorkspaceId,
|
|
sp: &SessionPane,
|
|
alive: Option<&std::collections::HashMap<u64, Option<String>>>,
|
|
font_size: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) -> Option<Pane> {
|
|
match sp {
|
|
SessionPane::Leaf {
|
|
cwd,
|
|
pane_id,
|
|
shell,
|
|
ssh_spec,
|
|
agent,
|
|
agent_session_id,
|
|
agent_launch_argv,
|
|
} => {
|
|
let same_daemon =
|
|
leaf_shares_the_window_daemon(workspace.is_some(), ssh_spec.is_some());
|
|
let restore = match workspace.is_some() {
|
|
true => (*pane_id).filter(|_| same_daemon),
|
|
// Not `pane_attachable`: a dead pane's id is what the restore
|
|
// is keyed on, so it has to survive being dead. The attach is
|
|
// still attempted first and still gives way to a fresh spawn.
|
|
false => (*pane_id).filter(|id| same_daemon && pane_free_for(alive, *id, owner)),
|
|
};
|
|
if restore.is_none() {
|
|
if let Some(spec) = ssh_spec.clone() {
|
|
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
|
|
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
|
|
Ok(view) => return Some(Pane::leaf(PaneSlot::Ready(view))),
|
|
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
|
|
}
|
|
}
|
|
}
|
|
let view = match new_terminal(
|
|
workspace.cloned(),
|
|
Some(owner),
|
|
font_size,
|
|
cwd.clone(),
|
|
restore,
|
|
shell.clone(),
|
|
window,
|
|
cx,
|
|
) {
|
|
Ok(view) => view,
|
|
Err(e) => {
|
|
log::error!("restoring pane failed: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
match &view {
|
|
PaneSlot::Ready(terminal) if !terminal.read(cx).restored() => {
|
|
if let Some(cmd) = agent_resume_command(
|
|
agent,
|
|
agent_session_id.as_deref(),
|
|
agent_launch_argv.as_deref(),
|
|
cx,
|
|
) {
|
|
terminal.read(cx).run_command_line(&cmd);
|
|
}
|
|
}
|
|
PaneSlot::Ready(_) => {}
|
|
PaneSlot::Connecting(pending) => {
|
|
pending.update(cx, |pending, _| {
|
|
pending.spawn.agent = *agent;
|
|
pending.spawn.agent_session_id = agent_session_id.clone();
|
|
pending.spawn.agent_launch_argv = agent_launch_argv.clone();
|
|
});
|
|
}
|
|
}
|
|
Some(Pane::leaf(view))
|
|
}
|
|
SessionPane::Split { axis, ratio, a, b } => {
|
|
let axis = match axis {
|
|
SessionAxis::Horizontal => Axis::Horizontal,
|
|
SessionAxis::Vertical => Axis::Vertical,
|
|
};
|
|
match (
|
|
session_to_pane(workspace, owner, a, alive, font_size, window, cx),
|
|
session_to_pane(workspace, owner, b, alive, font_size, window, cx),
|
|
) {
|
|
(Some(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)),
|
|
(Some(only), None) | (None, Some(only)) => Some(only),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn new_terminal(
|
|
workspace: Option<crate::terminal::PaneWorkspace>,
|
|
owner: Option<WorkspaceId>,
|
|
font_size: f32,
|
|
working_directory: Option<std::path::PathBuf>,
|
|
restore_pane: Option<u64>,
|
|
shell: Option<ShellSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) -> anyhow::Result<PaneSlot> {
|
|
if matches!(
|
|
crate::terminal::PaneRoute::for_workspace(workspace.as_ref()),
|
|
crate::terminal::PaneRoute::Local
|
|
) {
|
|
let parts = TerminalView::spawn_shell_terminal_in(
|
|
workspace,
|
|
working_directory,
|
|
restore_pane,
|
|
shell,
|
|
owner,
|
|
)?;
|
|
return Ok(PaneSlot::Ready(build_terminal_view(
|
|
parts, font_size, window, cx,
|
|
)));
|
|
}
|
|
|
|
let spawn = crate::ui::pending_pane::PendingSpawn {
|
|
workspace,
|
|
working_directory,
|
|
restore_pane,
|
|
shell,
|
|
agent: None,
|
|
agent_session_id: None,
|
|
agent_launch_argv: None,
|
|
owner,
|
|
font_size,
|
|
};
|
|
// The same leak the reconnect banner had: this name is read out as
|
|
// "Connecting to {machine}…" and "Could not reach {machine}", and a
|
|
// `Profile` target spells itself as its config UUID (#485). The pane's own
|
|
// notifications already resolve it through the live config.
|
|
let machine = spawn
|
|
.workspace
|
|
.as_ref()
|
|
.map(|w| crate::ui::remote_connect::target_label(cx, &w.target))
|
|
.unwrap_or_else(|| t(L10nKey::AppLocalServerName).to_string());
|
|
let pending = cx.new(|cx| crate::ui::pending_pane::PendingPane::new(machine, spawn, cx));
|
|
cx.subscribe_in(
|
|
&pending,
|
|
window,
|
|
|_app, pending, _: &crate::ui::pending_pane::RetryRequested, window, cx| {
|
|
start_pane_spawn(pending.clone(), window, cx);
|
|
},
|
|
)
|
|
.detach();
|
|
let handle = pending.read(cx).focus_handle.clone();
|
|
watch_pane_focus(&handle, pending.entity_id(), window, cx);
|
|
start_pane_spawn(pending.clone(), window, cx);
|
|
Ok(PaneSlot::Connecting(pending))
|
|
}
|
|
|
|
fn start_pane_spawn(
|
|
pending: Entity<crate::ui::pending_pane::PendingPane>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) {
|
|
let spawn = pending.read(cx).spawn.clone();
|
|
let slot_id = pending.entity_id();
|
|
let font_size = spawn.font_size;
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let parts = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
TerminalView::spawn_shell_terminal_in(
|
|
spawn.workspace.clone(),
|
|
spawn.working_directory.clone(),
|
|
spawn.restore_pane,
|
|
spawn.shell.clone(),
|
|
spawn.owner,
|
|
)
|
|
.map_err(|e| format!("{e:#}"))
|
|
})
|
|
.await;
|
|
let _ = this.update_in(cx, |app, window, cx| {
|
|
app.land_pane(slot_id, &pending, parts, font_size, window, cx);
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn build_terminal_view(
|
|
parts: crate::terminal::view::ShellParts,
|
|
font_size: f32,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) -> Entity<TerminalView> {
|
|
let view = cx.new(|cx| {
|
|
let mut view = TerminalView::from_shell_parts(parts, window, cx);
|
|
view.font_size = px(font_size);
|
|
view
|
|
});
|
|
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
|
|
app.on_child_exited(view.clone(), window, cx);
|
|
})
|
|
.detach();
|
|
cx.subscribe_in(
|
|
&view,
|
|
window,
|
|
|app, _view, _: &crate::terminal::view::AgentSessionChanged, _window, cx| {
|
|
app.save_session(cx);
|
|
},
|
|
)
|
|
.detach();
|
|
cx.subscribe_in(
|
|
&view,
|
|
window,
|
|
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
|
|
app.on_auth_prompt_ready(view.clone(), window, cx);
|
|
},
|
|
)
|
|
.detach();
|
|
watch_open_file_requests(&view, window, cx);
|
|
let handle = view.read(cx).focus_handle.clone();
|
|
watch_pane_focus(&handle, view.entity_id(), window, cx);
|
|
view
|
|
}
|
|
|
|
fn kill_pane_off_thread(route: crate::terminal::PaneRoute, pane_id: u64, cx: &mut App) {
|
|
cx.background_executor()
|
|
.spawn(async move { crate::terminal::RemoteTerminal::kill_pane_on(&route, pane_id) })
|
|
.detach();
|
|
}
|
|
|
|
/// Routes the file links clicked in a pane to whatever the app opens files
|
|
/// with. Every pane needs this, however it was spawned — a link in an SSH pane
|
|
/// is the same click as a link in a local one.
|
|
fn watch_open_file_requests(
|
|
view: &Entity<TerminalView>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) {
|
|
cx.subscribe_in(
|
|
view,
|
|
window,
|
|
|app, _view, ev: &crate::terminal::view::OpenFileRequested, window, cx| {
|
|
app.open_linked_file(&ev.path, ev.line, ev.column, ev.is_dir, window, cx);
|
|
},
|
|
)
|
|
.detach();
|
|
}
|
|
|
|
/// Record `leaf` as the pane the tab holding it comes back to.
|
|
///
|
|
/// Which tab that is gets asked of the layout rather than assumed to be the
|
|
/// active one: a pane dragged into another tab takes focus with it, and it is
|
|
/// the tab holding it now whose memory the arrival should change. A leaf no
|
|
/// tab holds — one that has just closed, or arrived after its slot went away —
|
|
/// is recorded nowhere.
|
|
fn remember_leaf_in(tabs: &mut [Tab], leaf: gpui::EntityId) {
|
|
let held = tabs
|
|
.iter_mut()
|
|
.find(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == leaf));
|
|
if let Some(tab) = held {
|
|
tab.last_focused = Some(leaf);
|
|
}
|
|
}
|
|
|
|
/// Put `new` where the slot `old` named stood, carrying that tab's focus
|
|
/// memory across with it.
|
|
///
|
|
/// The memory has to move because the id it holds does not survive the swap.
|
|
/// Focus arriving in a pane that is still coming up is recorded against the
|
|
/// *pending* slot — that is why connecting slots are watched at all — and that
|
|
/// slot's id dies the moment the pane lands. Left behind, the memory names an
|
|
/// entity no tab holds, `focus_target` falls through `leaf_matching_or_first`,
|
|
/// and the tab comes back to its first leaf: #843 again, one landing later.
|
|
///
|
|
/// Nothing else writes the answer down in that case. `land_pane` re-focuses
|
|
/// the pane it built only when the pending slot still held focus, and with
|
|
/// focus off the panes the switch-away sample has nothing to read either.
|
|
fn replace_leaf_in(tabs: &mut [Tab], old: gpui::EntityId, new: PaneSlot) {
|
|
for tab in tabs.iter_mut() {
|
|
if tab.pane.replace_leaf(old, new.clone()) {
|
|
if tab.last_focused == Some(old) {
|
|
tab.last_focused = Some(new.entity_id());
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Repaint the chrome that marks the focused pane, and record the leaf as the
|
|
/// one its tab returns to (#843).
|
|
///
|
|
/// Every leaf is watched, connecting slots included: a pane can be focused
|
|
/// while it is still coming up, and if the tab is left in that moment the
|
|
/// answer has to already be written down.
|
|
fn watch_pane_focus(
|
|
handle: &gpui::FocusHandle,
|
|
leaf: gpui::EntityId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) {
|
|
let app = cx.weak_entity();
|
|
window
|
|
.on_focus_in(handle, cx, move |_window, cx| {
|
|
if let Some(app) = app.upgrade() {
|
|
app.update(cx, |app, cx| {
|
|
app.remember_focused_leaf(leaf);
|
|
cx.notify();
|
|
});
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn new_terminal_native(
|
|
font_size: f32,
|
|
working_directory: Option<std::path::PathBuf>,
|
|
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Tty7App>,
|
|
) -> anyhow::Result<Entity<TerminalView>> {
|
|
let parts = TerminalView::spawn_native_ssh_terminal(spec, working_directory)?;
|
|
let view = cx.new(|cx| {
|
|
let mut view = TerminalView::from_native_ssh_parts(parts, window, cx);
|
|
view.font_size = px(font_size);
|
|
view
|
|
});
|
|
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
|
|
app.on_child_exited(view.clone(), window, cx);
|
|
})
|
|
.detach();
|
|
cx.subscribe_in(
|
|
&view,
|
|
window,
|
|
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
|
|
app.on_auth_prompt_ready(view.clone(), window, cx);
|
|
},
|
|
)
|
|
.detach();
|
|
watch_open_file_requests(&view, window, cx);
|
|
let handle = view.read(cx).focus_handle.clone();
|
|
watch_pane_focus(&handle, view.entity_id(), window, cx);
|
|
Ok(view)
|
|
}
|
|
|
|
/// Reads the Settings "Shell Arguments" field as a command line (#551).
|
|
///
|
|
/// The field is one line of text standing in for the `shell.args` array, so
|
|
/// this and [`join_shell_args`] have to be exact inverses of each other — the
|
|
/// field is refilled from the array every time Settings opens, and committed
|
|
/// back on every blur. Quoting rules are the smallest set that can spell any
|
|
/// argv: single quotes take everything literally, double quotes take everything
|
|
/// literally except `\"` and `\\`, and whitespace outside quotes separates
|
|
/// words.
|
|
///
|
|
/// A lone backslash outside quotes is *not* an escape, unlike a POSIX shell:
|
|
/// these arguments are handed to the process as argv without a shell in
|
|
/// between, and `--dir C:\Users\me` has to keep its backslashes on the platform
|
|
/// where that is how a path is spelled. `#` starts no comment here either, for
|
|
/// the same reason: nothing in an argv list is a comment, and swallowing the
|
|
/// rest of the line would be the silent rewrite this whole change exists to
|
|
/// stop.
|
|
///
|
|
/// Returns `Err` when a quote never closes: that text names no argv at all.
|
|
pub(crate) fn split_shell_args(input: &str) -> Result<Vec<String>, ()> {
|
|
let mut words = Vec::new();
|
|
let mut current = String::new();
|
|
// A word can be empty and still be a word — `''` is a real argument that
|
|
// whitespace splitting cannot spell, so emptiness alone cannot end one.
|
|
let mut started = false;
|
|
let mut quote: Option<char> = None;
|
|
let mut chars = input.chars();
|
|
while let Some(ch) = chars.next() {
|
|
match (quote, ch) {
|
|
(Some(q), c) if c == q => quote = None,
|
|
// Only inside double quotes, and only for the two characters that
|
|
// would otherwise be unspellable there.
|
|
(Some('"'), '\\') => match chars.clone().next() {
|
|
Some(next @ ('"' | '\\')) => {
|
|
chars.next();
|
|
current.push(next);
|
|
}
|
|
_ => current.push('\\'),
|
|
},
|
|
(Some(_), c) => current.push(c),
|
|
(None, '\'' | '"') => {
|
|
quote = Some(ch);
|
|
started = true;
|
|
}
|
|
(None, c) if c.is_whitespace() => {
|
|
if started {
|
|
words.push(std::mem::take(&mut current));
|
|
started = false;
|
|
}
|
|
}
|
|
(None, c) => {
|
|
current.push(c);
|
|
started = true;
|
|
}
|
|
}
|
|
}
|
|
if quote.is_some() {
|
|
return Err(());
|
|
}
|
|
if started {
|
|
words.push(current);
|
|
}
|
|
Ok(words)
|
|
}
|
|
|
|
/// Writes an argv array back into the Settings "Shell Arguments" field (#551).
|
|
///
|
|
/// The exact inverse of [`split_shell_args`], and quotes only what it must:
|
|
/// `join(" ")` cannot spell an argument that contains a space, so a legal
|
|
/// `"args": ["-c", "echo hi"]` used to refill as three words and re-commit as
|
|
/// three argv on the next blur. Arguments that need no quoting are written
|
|
/// bare, so the common `--login --color=auto` still reads as the user typed it.
|
|
pub(crate) fn join_shell_args(args: &[String]) -> String {
|
|
args.iter()
|
|
.map(|arg| quote_shell_arg(arg))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn quote_shell_arg(arg: &str) -> String {
|
|
if !arg.is_empty()
|
|
&& !arg
|
|
.chars()
|
|
.any(|c| c.is_whitespace() || c == '\'' || c == '"')
|
|
{
|
|
return arg.to_string();
|
|
}
|
|
// Single quotes need no escapes at all, so they are the first choice; an
|
|
// argument containing one falls back to double quotes, which can escape.
|
|
if !arg.contains('\'') {
|
|
return format!("'{arg}'");
|
|
}
|
|
let mut quoted = String::with_capacity(arg.len() + 2);
|
|
quoted.push('"');
|
|
for c in arg.chars() {
|
|
if c == '"' || c == '\\' {
|
|
quoted.push('\\');
|
|
}
|
|
quoted.push(c);
|
|
}
|
|
quoted.push('"');
|
|
quoted
|
|
}
|
|
|
|
/// The one rule the "Start in" custom path lives by (#601): empty means unset
|
|
/// and saves; anything else must name a directory that exists, because the
|
|
/// daemon's picker skips a path that is not one and every new pane then
|
|
/// silently starts somewhere else. Settings refuses to save such a value and
|
|
/// marks it red — both decide through this, so the red line and the not-saved
|
|
/// config always agree. Local on purpose: this is the local daemon's config.
|
|
pub(crate) fn wd_path_saveable(path: &str) -> bool {
|
|
let path = path.trim();
|
|
path.is_empty() || std::path::Path::new(path).is_dir()
|
|
}
|
|
|
|
pub(crate) fn parse_ssh_option_words(input: &str) -> Result<Vec<String>, ()> {
|
|
let mut words = Vec::new();
|
|
let mut current = String::new();
|
|
let mut quote = None;
|
|
let mut chars = input.chars();
|
|
while let Some(ch) = chars.next() {
|
|
match (quote, ch) {
|
|
(Some(q), c) if c == q => quote = None,
|
|
(Some(_), '\\') => {
|
|
if let Some(next) = chars.next() {
|
|
current.push(next);
|
|
}
|
|
}
|
|
(Some(_), c) => current.push(c),
|
|
(None, '\'' | '"') => quote = Some(ch),
|
|
(None, c) if c.is_whitespace() => {
|
|
if !current.is_empty() {
|
|
words.push(std::mem::take(&mut current));
|
|
}
|
|
}
|
|
(None, c) => current.push(c),
|
|
}
|
|
}
|
|
if quote.is_some() {
|
|
return Err(());
|
|
}
|
|
if !current.is_empty() {
|
|
words.push(current);
|
|
}
|
|
Ok(words)
|
|
}
|
|
|
|
pub(crate) struct ParsedSshConnect {
|
|
pub profile: crate::core::ssh_profile::SshProfile,
|
|
pub proxy_jump: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, String> {
|
|
use crate::core::ssh_profile::{SshProfile, parse_quick_connect};
|
|
|
|
let mut words = parse_ssh_option_words(input)
|
|
.map_err(|_| t(L10nKey::AppSshParseUnbalancedQuotes).to_string())?;
|
|
if words.first().is_some_and(|word| word == "ssh") {
|
|
words.remove(0);
|
|
}
|
|
|
|
let mut target: Option<String> = None;
|
|
let mut user: Option<String> = None;
|
|
let mut port: Option<u16> = None;
|
|
let mut identities: Vec<String> = Vec::new();
|
|
let mut jump: Option<String> = None;
|
|
|
|
let mut i = 0;
|
|
while i < words.len() {
|
|
let word = words[i].clone();
|
|
if word == "--" {
|
|
return Err(t(L10nKey::AppSshParseNoRemoteCommands).to_string());
|
|
}
|
|
if let Some((flag, attached)) = ssh_short_flag(&word) {
|
|
let value = if ssh_option_takes_value(flag) {
|
|
if !attached.is_empty() {
|
|
attached
|
|
} else {
|
|
i += 1;
|
|
match words.get(i) {
|
|
Some(v) => v.clone(),
|
|
None => {
|
|
return Err(t_fmt(
|
|
L10nKey::AppSshParseFlagNeedsValue,
|
|
&[("flag", &flag.to_string())],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
match flag {
|
|
'p' => {
|
|
port = Some(
|
|
value
|
|
.parse::<u16>()
|
|
.ok()
|
|
.filter(|&p| p != 0)
|
|
.ok_or_else(|| {
|
|
t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", &value)])
|
|
})?,
|
|
)
|
|
}
|
|
'l' => user = Some(value),
|
|
'i' => identities.push(value),
|
|
'J' => jump = Some(value),
|
|
'o' => apply_ssh_o_option(&value, &mut user, &mut port, &mut jump)?,
|
|
_ => {}
|
|
}
|
|
} else if word.starts_with('-') {
|
|
return Err(t_fmt(
|
|
L10nKey::AppSshParseUnsupportedOption,
|
|
&[("option", &word)],
|
|
));
|
|
} else if target.is_none() {
|
|
target = Some(word);
|
|
} else {
|
|
return Err("Remote commands aren't supported here".to_string());
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
let target = target.ok_or_else(|| t(L10nKey::AppSshParseEnterHost).to_string())?;
|
|
let qc = parse_quick_connect(&target)
|
|
.ok_or_else(|| t_fmt(L10nKey::AppSshParseBadHost, &[("host", &target)]))?;
|
|
|
|
let mut profile = SshProfile::new(qc.host.clone());
|
|
profile.host = qc.host;
|
|
profile.port = port.or(qc.port).unwrap_or(22);
|
|
if let Some(user) = user.or(qc.user) {
|
|
profile.user = user;
|
|
}
|
|
profile.identity_files = identities;
|
|
|
|
Ok(ParsedSshConnect {
|
|
profile,
|
|
proxy_jump: jump,
|
|
})
|
|
}
|
|
|
|
fn ssh_short_flag(word: &str) -> Option<(char, String)> {
|
|
let rest = word.strip_prefix('-')?;
|
|
if rest.is_empty() || rest.starts_with('-') {
|
|
return None;
|
|
}
|
|
let mut chars = rest.chars();
|
|
let flag = chars.next()?;
|
|
Some((flag, chars.as_str().to_string()))
|
|
}
|
|
|
|
fn apply_ssh_o_option(
|
|
value: &str,
|
|
user: &mut Option<String>,
|
|
port: &mut Option<u16>,
|
|
jump: &mut Option<String>,
|
|
) -> Result<(), String> {
|
|
let Some((name, val)) = value.split_once('=') else {
|
|
return Ok(());
|
|
};
|
|
match name.to_ascii_lowercase().as_str() {
|
|
"user" => *user = Some(val.to_string()),
|
|
"port" => {
|
|
*port = Some(
|
|
val.parse::<u16>()
|
|
.ok()
|
|
.filter(|&p| p != 0)
|
|
.ok_or_else(|| t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", val)]))?,
|
|
)
|
|
}
|
|
"proxyjump" => *jump = Some(val.to_string()),
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod window_drag_tests {
|
|
use gpui::{
|
|
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, MouseDownEvent,
|
|
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, PlatformInput, Point, Render,
|
|
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
|
};
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
struct Host;
|
|
impl Render for Host {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
div().size_full().child(super::title_bar_drag(
|
|
div().id("stand-in-title-bar").w_full().h(px(40.)),
|
|
"stand-in-title-bar",
|
|
window,
|
|
cx,
|
|
))
|
|
}
|
|
}
|
|
|
|
struct HandleOverRow {
|
|
occluded: bool,
|
|
}
|
|
impl Render for HandleOverRow {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let handle = div()
|
|
.absolute()
|
|
.top_0()
|
|
.left(px(296.))
|
|
.w(px(8.))
|
|
.h_full()
|
|
.cursor_col_resize()
|
|
.on_mouse_down(MouseButton::Left, |_, window, _| window.refresh());
|
|
let handle = if self.occluded {
|
|
handle.occlude()
|
|
} else {
|
|
handle
|
|
};
|
|
div()
|
|
.relative()
|
|
.size_full()
|
|
.child(super::title_bar_drag(
|
|
div().id("stand-in-title-bar").w_full().h(px(40.)),
|
|
"stand-in-title-bar",
|
|
window,
|
|
cx,
|
|
))
|
|
.child(handle)
|
|
}
|
|
}
|
|
|
|
struct PerFrameCellHost;
|
|
impl Render for PerFrameCellHost {
|
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
let should_move = Rc::new(Cell::new(false));
|
|
div().size_full().child(
|
|
div()
|
|
.id("per-frame-cell")
|
|
.w_full()
|
|
.h(px(40.))
|
|
.on_mouse_down(MouseButton::Left, {
|
|
let should_move = should_move.clone();
|
|
move |_, _, _| should_move.set(true)
|
|
})
|
|
.on_mouse_move(move |_, window, _| {
|
|
if should_move.replace(false) {
|
|
window.start_window_move();
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn down(at: Point<Pixels>) -> PlatformInput {
|
|
PlatformInput::MouseDown(MouseDownEvent {
|
|
button: MouseButton::Left,
|
|
position: at,
|
|
modifiers: Modifiers::none(),
|
|
click_count: 1,
|
|
first_mouse: false,
|
|
})
|
|
}
|
|
|
|
fn up(at: Point<Pixels>) -> PlatformInput {
|
|
PlatformInput::MouseUp(MouseUpEvent {
|
|
button: MouseButton::Left,
|
|
position: at,
|
|
modifiers: Modifiers::none(),
|
|
click_count: 1,
|
|
})
|
|
}
|
|
|
|
fn moved(at: Point<Pixels>, held: bool) -> PlatformInput {
|
|
PlatformInput::MouseMove(MouseMoveEvent {
|
|
position: at,
|
|
pressed_button: held.then_some(MouseButton::Left),
|
|
modifiers: Modifiers::none(),
|
|
})
|
|
}
|
|
|
|
const ON_ROW: Point<Pixels> = Point {
|
|
x: px(300.),
|
|
y: px(20.),
|
|
};
|
|
|
|
fn drifted(at: Point<Pixels>) -> Point<Pixels> {
|
|
point(at.x + px(12.), at.y + px(3.))
|
|
}
|
|
|
|
fn press_repaint_move(vcx: &mut VisualTestContext, at: Point<Pixels>) {
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(at, false), cx);
|
|
window.dispatch_event(down(at), cx);
|
|
});
|
|
vcx.update(|window, _| window.refresh());
|
|
vcx.run_until_parked();
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(drifted(at), true), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
}
|
|
|
|
#[gpui::test]
|
|
#[should_panic(expected = "not implemented")]
|
|
fn the_arm_survives_a_repaint_between_press_and_move(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Host);
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
press_repaint_move(&mut vcx, ON_ROW);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_per_frame_cell_loses_the_arm_to_the_same_repaint(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| PerFrameCellHost);
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
press_repaint_move(&mut vcx, ON_ROW);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_press_alone_does_not_move_the_window(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Host);
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(ON_ROW, false), cx);
|
|
window.dispatch_event(down(ON_ROW), cx);
|
|
window.dispatch_event(up(ON_ROW), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_release_disarms_so_a_later_hover_does_not_drag(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Host);
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(ON_ROW, false), cx);
|
|
window.dispatch_event(down(ON_ROW), cx);
|
|
window.dispatch_event(up(ON_ROW), cx);
|
|
});
|
|
vcx.update(|window, _| window.refresh());
|
|
vcx.run_until_parked();
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(drifted(ON_ROW), false), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_press_on_a_resize_handle_does_not_move_the_window(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| HandleOverRow { occluded: true });
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
press_repaint_move(&mut vcx, ON_ROW);
|
|
}
|
|
|
|
#[gpui::test]
|
|
#[should_panic(expected = "not implemented")]
|
|
fn a_handle_without_a_blocking_hitbox_hands_the_press_to_the_row(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| HandleOverRow { occluded: false });
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
press_repaint_move(&mut vcx, ON_ROW);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn two_rows_on_screen_keep_separate_arms(cx: &mut TestAppContext) {
|
|
struct TwoRows;
|
|
impl Render for TwoRows {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
div()
|
|
.size_full()
|
|
.child(super::title_bar_drag(
|
|
div().id("row-a").w_full().h(px(40.)),
|
|
"row-a",
|
|
window,
|
|
cx,
|
|
))
|
|
.child(super::title_bar_drag(
|
|
div().id("row-b").w_full().h(px(40.)),
|
|
"row-b",
|
|
window,
|
|
cx,
|
|
))
|
|
}
|
|
}
|
|
|
|
let window = cx.add_window(|_, _| TwoRows);
|
|
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(point(px(300.), px(20.)), false), cx);
|
|
window.dispatch_event(down(point(px(300.), px(20.))), cx);
|
|
});
|
|
vcx.update(|window, _| window.refresh());
|
|
vcx.run_until_parked();
|
|
vcx.update(|window, cx| {
|
|
window.dispatch_event(moved(point(px(312.), px(60.)), false), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
CloseReason, DOCUMENT_MIN_W, Dir, Pane, Rename, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, Tab,
|
|
TabAgentSession, clear_window_override_values, close_prompt, document_column_px,
|
|
join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for,
|
|
parse_ssh_connect_input, parse_ssh_option_words, rename_outcome, side_panel_max,
|
|
split_shell_args, step_in_order, strip_band, wd_path_saveable,
|
|
};
|
|
use gpui::{Edges, point, px, size};
|
|
|
|
#[test]
|
|
fn a_rename_box_left_alone_is_not_a_rename() {
|
|
// The box opens holding the label already on screen, and `Blur`
|
|
// commits — so this is what happens when the user opens it, thinks
|
|
// better of it, and clicks away.
|
|
assert_eq!(
|
|
rename_outcome("api", "api"),
|
|
Rename::Unchanged,
|
|
"an untouched box asked for nothing"
|
|
);
|
|
assert_eq!(
|
|
rename_outcome(" api ", "api"),
|
|
Rename::Unchanged,
|
|
"whitespace either side is not an edit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_emptied_rename_box_gives_the_tab_back_to_its_pane() {
|
|
// The only way to remove a name once set, so it has to survive the
|
|
// comparison above.
|
|
assert_eq!(rename_outcome("", "api"), Rename::Cleared);
|
|
assert_eq!(rename_outcome(" ", "api"), Rename::Cleared);
|
|
}
|
|
|
|
#[test]
|
|
fn a_typed_rename_box_names_the_tab() {
|
|
assert_eq!(
|
|
rename_outcome("billing", "api"),
|
|
Rename::Named("billing".into())
|
|
);
|
|
assert_eq!(
|
|
rename_outcome(" billing ", "api"),
|
|
Rename::Named("billing".into()),
|
|
"the name is stored trimmed"
|
|
);
|
|
}
|
|
|
|
const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH;
|
|
const PANEL_MIN: f32 = crate::ui::right_panel::MIN_WIDTH;
|
|
|
|
/// #738: which pane a directional move came from is remembered against the
|
|
/// pane it landed on, not against the direction alone.
|
|
///
|
|
/// One slot per direction is enough for a single move and back, but the
|
|
/// second step of a walk overwrites the first: left off `3` onto `2` and
|
|
/// left again onto `1` would leave only `1 -> 2`, and the second move back
|
|
/// right — the one out of `2` — would be handed no origin and fall to the
|
|
/// geometry that sent the user to the wrong pane in the first place.
|
|
#[test]
|
|
fn each_pane_remembers_the_move_that_landed_on_it() {
|
|
fn id(n: u64) -> gpui::EntityId {
|
|
gpui::EntityId::from(n)
|
|
}
|
|
let mut tab = Tab::new(Pane::Empty);
|
|
let live = [id(1), id(2), id(3)];
|
|
|
|
tab.remember_focus_origin(id(3), id(2), Dir::Left, &live);
|
|
tab.remember_focus_origin(id(2), id(1), Dir::Left, &live);
|
|
|
|
// Walking back retraces both steps rather than only the last one.
|
|
assert_eq!(tab.focus_origin(id(1), Dir::Right), Some(id(2)));
|
|
assert_eq!(tab.focus_origin(id(2), Dir::Right), Some(id(3)));
|
|
// Nothing is claimed about a direction no move went in, or about a pane
|
|
// no move has landed on.
|
|
assert_eq!(tab.focus_origin(id(2), Dir::Left), None);
|
|
assert_eq!(tab.focus_origin(id(3), Dir::Right), None);
|
|
|
|
// A pane the tab no longer holds takes its entries with it, on either
|
|
// side: with `3` closed, `2` no longer remembers having come from it,
|
|
// and the move back out of `2` is left to geometry.
|
|
tab.remember_focus_origin(id(1), id(2), Dir::Right, &[id(1), id(2)]);
|
|
assert_eq!(tab.focus_origin(id(2), Dir::Right), None);
|
|
assert_eq!(tab.focus_origin(id(2), Dir::Left), Some(id(1)));
|
|
}
|
|
|
|
/// #679: the band now starts at the frame padding rather than at the
|
|
/// window's corner, and off Linux CSD there is no padding to start at —
|
|
/// `window_paddings` answers `Edges::all(0)` under server-side decorations,
|
|
/// which is what macOS, Windows and a bare X session all report.
|
|
#[test]
|
|
fn an_undecorated_frame_leaves_the_strips_drop_band_where_it_was() {
|
|
let band = strip_band(size(px(1200.), px(800.)), Edges::all(px(0.)));
|
|
assert_eq!(band.origin, point(px(0.), px(0.)));
|
|
assert_eq!(band.size, size(px(1200.), px(TITLE_BAR_HEIGHT)));
|
|
}
|
|
|
|
/// The viewport measures the whole surface, shadow included, so the band
|
|
/// has to lose one padding at each end — not one twice over, and not none.
|
|
/// Getting it wrong puts the outermost chips outside the band drawn to hold
|
|
/// them, and a drop on them reads as a drop on nothing.
|
|
#[test]
|
|
fn a_client_side_frame_pulls_the_drop_band_in_by_the_shadow_on_both_sides() {
|
|
let viewport = size(px(1200.), px(800.));
|
|
let pad = Edges::all(px(12.));
|
|
let band = strip_band(viewport, pad);
|
|
|
|
assert_eq!(band.origin, point(pad.left, pad.top));
|
|
assert_eq!(
|
|
band.origin.x + band.size.width,
|
|
viewport.width - pad.right,
|
|
"the band must reach the far edge of the frame, not of the surface"
|
|
);
|
|
assert_eq!(band.size.height, px(TITLE_BAR_HEIGHT));
|
|
}
|
|
|
|
/// Fullscreen takes the window buttons and nothing else: the strip keeps
|
|
/// its row, and the room reserved for the buttons at its end comes back.
|
|
/// macOS never had any to take.
|
|
#[test]
|
|
fn fullscreen_drops_the_window_buttons_but_not_their_row() {
|
|
assert!(!super::window_controls_drawn(true));
|
|
assert_eq!(super::window_controls_w(true), 0.);
|
|
assert_eq!(
|
|
super::window_controls_drawn(false),
|
|
!cfg!(target_os = "macos")
|
|
);
|
|
assert_eq!(super::window_controls_w(false), super::WINDOW_CONTROLS_W);
|
|
assert_eq!(
|
|
crate::ui::tab_strip::trailing_chrome_w(true),
|
|
crate::ui::tab_strip::trailing_chrome_tiles_w()
|
|
);
|
|
}
|
|
|
|
/// A surface narrower than its own shadow is only reachable mid-resize, but
|
|
/// a negative width would make `Bounds::contains` answer for a rectangle
|
|
/// that is inside out.
|
|
#[test]
|
|
fn a_drop_band_narrower_than_its_frame_collapses_instead_of_inverting() {
|
|
let band = strip_band(size(px(10.), px(800.)), Edges::all(px(12.)));
|
|
assert_eq!(band.size.width, px(0.));
|
|
}
|
|
|
|
/// Two panels that each cap themselves at half the window leave the
|
|
/// terminal nothing when both are open, so the cap is what is left after
|
|
/// the terminal's floor and the *other* panel's floor — or half the window,
|
|
/// whichever binds harder.
|
|
#[test]
|
|
fn the_terminal_is_spoken_for_before_either_panel_is() {
|
|
// Mid width: the reservation binds, and a panel dragged to it with the
|
|
// other at its floor leaves the terminal exactly its floor. Two halves
|
|
// left it nothing.
|
|
let mid = 900.;
|
|
let sidebar = side_panel_max(mid, SIDEBAR_MIN, PANEL_MIN);
|
|
assert_eq!(sidebar, mid - TERMINAL_MIN_W - PANEL_MIN);
|
|
assert_eq!(mid - sidebar - PANEL_MIN, TERMINAL_MIN_W);
|
|
|
|
// With nothing on the other side, half the window already leaves the
|
|
// terminal the other half, so the older cap is the one that binds and
|
|
// a lone panel behaves exactly as it always did.
|
|
assert_eq!(side_panel_max(mid, SIDEBAR_MIN, 0.), mid / 2.);
|
|
}
|
|
|
|
/// A docked document is a third column in the same budget, so it has to be
|
|
/// reserved by the two panels the way they already reserve each other —
|
|
/// otherwise a panel dragged to its old limit takes the width out of the
|
|
/// document, which then has nowhere to take it from but the terminal.
|
|
#[test]
|
|
fn a_docked_document_is_reserved_by_the_panels_too() {
|
|
let wide = 1440.;
|
|
let max = side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN + DOCUMENT_MIN_W);
|
|
assert_eq!(
|
|
wide - SIDEBAR_MIN - DOCUMENT_MIN_W - max,
|
|
TERMINAL_MIN_W,
|
|
"a panel at its cap, with both other columns at their floors, leaves the terminal exactly its floor"
|
|
);
|
|
assert!(
|
|
max < side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN),
|
|
"the reservation only ever takes width away"
|
|
);
|
|
}
|
|
|
|
/// The floors are not what the panels are actually drawn at. Both are
|
|
/// draggable and both persist, so the budget has to be fed the live widths
|
|
/// or a widened sidebar is width the terminal silently loses.
|
|
#[test]
|
|
fn widened_panels_still_leave_the_terminal_its_floor() {
|
|
let viewport = 1440.;
|
|
// Both dragged well past their floors, and the document asked for the
|
|
// widest named share there is.
|
|
let body = viewport - 400. - 320.;
|
|
let document = document_column_px(body, 2. / 3.).expect("720 points seats both");
|
|
assert!(
|
|
body - document >= TERMINAL_MIN_W,
|
|
"terminal got {}",
|
|
body - document
|
|
);
|
|
|
|
// Squeezed further, the document is the one that gives up first — and
|
|
// then stops existing rather than dropping under its own floor.
|
|
let squeezed = viewport - 600. - 400.;
|
|
assert_eq!(document_column_px(squeezed, 0.5), None);
|
|
}
|
|
|
|
/// The reservation must only ever take width away from a panel. On a wide
|
|
/// window it works out *larger* than the half-window cap that was already
|
|
/// there, and letting it win would widen the ceiling instead.
|
|
#[test]
|
|
fn a_wide_window_still_stops_a_panel_at_half_of_it() {
|
|
let wide = 1440.;
|
|
assert!(
|
|
wide - TERMINAL_MIN_W - PANEL_MIN > wide / 2.,
|
|
"otherwise this test is not testing the case it names"
|
|
);
|
|
assert_eq!(side_panel_max(wide, SIDEBAR_MIN, PANEL_MIN), wide / 2.);
|
|
assert_eq!(side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN), wide / 2.);
|
|
}
|
|
|
|
/// Below the width where everything fits, the floor wins over the
|
|
/// reservation: a cap under a panel's own minimum would be a panel drawn
|
|
/// narrower than it can be read at, and the terminal — which can reflow —
|
|
/// takes the shortfall instead.
|
|
#[test]
|
|
fn a_window_too_narrow_for_all_three_falls_back_to_the_floors() {
|
|
let narrow = 720.;
|
|
assert_eq!(side_panel_max(narrow, SIDEBAR_MIN, PANEL_MIN), SIDEBAR_MIN);
|
|
assert_eq!(side_panel_max(narrow, PANEL_MIN, SIDEBAR_MIN), PANEL_MIN);
|
|
// Both panels pinned to their floors leaves the terminal the rest —
|
|
// less than its floor, but more than the 260-odd points two saved
|
|
// widths used to leave it.
|
|
assert!(narrow - SIDEBAR_MIN - PANEL_MIN > 300.);
|
|
}
|
|
|
|
#[test]
|
|
fn a_start_in_path_saves_only_when_it_names_a_real_directory() {
|
|
// Empty is "unset", not a broken path.
|
|
assert!(wd_path_saveable(""));
|
|
assert!(wd_path_saveable(" "));
|
|
let real = std::env::temp_dir();
|
|
let real = real.to_str().expect("temp dir is utf-8 here");
|
|
assert!(wd_path_saveable(real), "{real} exists");
|
|
assert!(
|
|
wd_path_saveable(&format!(" {real} ")),
|
|
"the commit trims, so the check trims too"
|
|
);
|
|
assert!(!wd_path_saveable("/definitely/not/a/real/dir"));
|
|
// A file is not a directory the shell can start in.
|
|
let file = std::env::temp_dir().join("tty7-wd-saveable-probe");
|
|
std::fs::write(&file, b"x").expect("write probe file");
|
|
assert!(!wd_path_saveable(file.to_str().expect("utf-8")));
|
|
let _ = std::fs::remove_file(&file);
|
|
}
|
|
|
|
#[test]
|
|
fn the_close_question_names_what_it_is_about_to_end() {
|
|
use crate::terminal::view::PaneBusy;
|
|
crate::ui::i18n::set_locale("en");
|
|
|
|
let build = CloseReason::Busy(PaneBusy::Command("cargo build".into()));
|
|
// The command is in the body, not a generic "are you sure".
|
|
let (title, body) = close_prompt(true, &build);
|
|
assert!(body.contains("cargo build"), "{body}");
|
|
assert!(!body.contains("{what}"), "the placeholder leaked: {body}");
|
|
assert!(title.contains("tab"), "{title}");
|
|
// Same reason, but the pane has siblings: the tab survives, so the
|
|
// question must not claim otherwise.
|
|
let (pane_title, _) = close_prompt(false, &build);
|
|
assert!(pane_title.contains("pane"), "{pane_title}");
|
|
assert_ne!(title, pane_title);
|
|
|
|
let agent = CloseReason::Busy(PaneBusy::Agent("Claude Code"));
|
|
let (_, body) = close_prompt(true, &agent);
|
|
assert!(body.contains("Claude Code"), "{body}");
|
|
assert!(!body.contains("{agent}"), "the placeholder leaked: {body}");
|
|
|
|
// A live SSH connection keeps its own wording rather than being folded
|
|
// into the busy copy.
|
|
let (ssh_title, ssh_body) = close_prompt(true, &CloseReason::LiveSsh);
|
|
assert_ne!(ssh_title, title);
|
|
assert!(!ssh_body.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_layout_that_came_back_short_says_how_short() {
|
|
crate::ui::i18n::set_locale("en");
|
|
for n in [1usize, 2, 9] {
|
|
let text =
|
|
crate::ui::i18n::t_plural(crate::ui::i18n::L10nKey::AppTabsNotRestored, n, &[]);
|
|
assert!(text.contains(&n.to_string()), "{n}: {text}");
|
|
assert!(!text.contains("{count}"), "the placeholder leaked: {text}");
|
|
}
|
|
// Singular is not "1 tabs".
|
|
let one = crate::ui::i18n::t_plural(crate::ui::i18n::L10nKey::AppTabsNotRestored, 1, &[]);
|
|
assert!(one.contains("1 tab "), "{one}");
|
|
}
|
|
|
|
#[test]
|
|
fn non_windows_reset_preserves_the_synced_windows_backdrop() {
|
|
let mut config = crate::core::config::Config::default();
|
|
config.window_opacity = Some(0.8);
|
|
config.window_blur = Some(true);
|
|
config.window_backdrop = crate::core::config::WindowBackdrop::Mica;
|
|
|
|
clear_window_override_values(&mut config, false);
|
|
|
|
assert_eq!(config.window_opacity, None);
|
|
assert_eq!(config.window_blur, None);
|
|
assert_eq!(
|
|
config.window_backdrop,
|
|
crate::core::config::WindowBackdrop::Mica,
|
|
"an inert synchronized backdrop is not a local override to reset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_reset_clears_the_local_backdrop_override() {
|
|
let mut config = crate::core::config::Config::default();
|
|
config.window_backdrop = crate::core::config::WindowBackdrop::Acrylic;
|
|
|
|
clear_window_override_values(&mut config, true);
|
|
|
|
assert_eq!(
|
|
config.window_backdrop,
|
|
crate::core::config::WindowBackdrop::Auto
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mru_puts_the_active_tab_first_and_the_last_one_used_behind_it() {
|
|
// Tab 2 is active; 0 was used most recently before it, then 1.
|
|
assert_eq!(mru_order(&[7, 4, 9], 2), vec![2, 0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn mru_leads_with_the_active_tab_even_before_its_stamp_lands() {
|
|
assert_eq!(mru_order(&[5, 0, 3], 1), vec![1, 0, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn mru_trails_never_activated_tabs_in_strip_order() {
|
|
assert_eq!(mru_order(&[0, 6, 0, 0], 1), vec![1, 0, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn mru_of_a_windowless_workspace_is_empty() {
|
|
assert!(mru_order(&[], 0).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn stepping_through_tabs_follows_the_strip_and_wraps() {
|
|
let order = [0, 1, 2];
|
|
assert_eq!(step_in_order(&order, 0, true), Some(1));
|
|
assert_eq!(step_in_order(&order, 2, true), Some(0));
|
|
assert_eq!(step_in_order(&order, 0, false), Some(2));
|
|
assert_eq!(step_in_order(&order, 1, false), Some(0));
|
|
// Pressing it again keeps going — this is not the MRU switcher, which
|
|
// bounces between the last two tabs (#867).
|
|
let mut at = 0;
|
|
let seen: Vec<usize> = (0..4)
|
|
.map(|_| {
|
|
at = step_in_order(&order, at, true).unwrap();
|
|
at
|
|
})
|
|
.collect();
|
|
assert_eq!(seen, vec![1, 2, 0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn stepping_follows_the_grouped_sidebar_order_not_the_index() {
|
|
// Sidebar groups reorder the rows: index 2 is shown second.
|
|
let order = [0, 2, 1, 3];
|
|
assert_eq!(step_in_order(&order, 0, true), Some(2));
|
|
assert_eq!(step_in_order(&order, 2, true), Some(1));
|
|
assert_eq!(step_in_order(&order, 3, true), Some(0));
|
|
assert_eq!(step_in_order(&order, 0, false), Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn stepping_has_nowhere_to_go_with_one_tab() {
|
|
assert_eq!(step_in_order(&[], 0, true), None);
|
|
assert_eq!(step_in_order(&[0], 0, true), None);
|
|
assert_eq!(step_in_order(&[0], 0, false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() {
|
|
let ours = crate::core::session::WorkspaceId::new();
|
|
let theirs = crate::core::session::WorkspaceId::new();
|
|
let alive: std::collections::HashMap<u64, Option<String>> = [
|
|
(1, Some(ours.to_string())),
|
|
(2, Some(theirs.to_string())),
|
|
(3, None),
|
|
(5, Some("tty7-cli".to_string())),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
|
|
assert!(
|
|
pane_free_for(Some(&alive), 1, ours),
|
|
"our own pane attaches"
|
|
);
|
|
assert!(
|
|
!pane_free_for(Some(&alive), 2, ours),
|
|
"another workspace's pane must spawn fresh instead"
|
|
);
|
|
assert!(
|
|
pane_free_for(Some(&alive), 3, ours),
|
|
"an unowned pane is legacy"
|
|
);
|
|
assert!(
|
|
pane_free_for(Some(&alive), 5, ours),
|
|
"an owner that names no workspace is not a rival's claim: older CLIs \
|
|
wrote their own name there, and respawning strands the live pane"
|
|
);
|
|
assert!(
|
|
pane_free_for(None, 4, ours),
|
|
"a failed List says nothing about pane 4; the attach itself must decide, \
|
|
because respawning on a transient RPC error destroys a live session"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dead_pane_keeps_its_id_so_its_screen_can_be_asked_for() {
|
|
let ours = crate::core::session::WorkspaceId::new();
|
|
let theirs = crate::core::session::WorkspaceId::new();
|
|
let alive: std::collections::HashMap<u64, Option<String>> =
|
|
[(1, Some(ours.to_string())), (2, Some(theirs.to_string()))]
|
|
.into_iter()
|
|
.collect();
|
|
|
|
// The restart case: every pane the window held is missing from the new
|
|
// daemon's listing. Their ids are the only handle on the screens it
|
|
// still has stored, so being dead must not erase them — this is what
|
|
// made a restarted server come back to a row of blank shells.
|
|
assert!(
|
|
pane_free_for(Some(&alive), 4, ours),
|
|
"a dead pane's id has to survive; the restore is keyed on it"
|
|
);
|
|
|
|
// What being free does not mean: helping yourself to a pane that is
|
|
// alive and belongs to another workspace, whose screen is not this
|
|
// window's to show either.
|
|
assert!(
|
|
!pane_free_for(Some(&alive), 2, ours),
|
|
"another workspace's pane is not ours to restore from"
|
|
);
|
|
assert!(pane_free_for(Some(&alive), 1, ours), "our own pane is ours");
|
|
}
|
|
|
|
#[test]
|
|
fn a_native_ssh_leaf_in_a_remote_window_is_not_looked_up_in_the_remote_daemon() {
|
|
assert!(!leaf_shares_the_window_daemon(true, true));
|
|
assert!(leaf_shares_the_window_daemon(true, false));
|
|
assert!(leaf_shares_the_window_daemon(false, true));
|
|
assert!(leaf_shares_the_window_daemon(false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_fork_needs_a_command_an_id_and_a_local_pane() {
|
|
let session = |fork_label, session_id: Option<&str>, remote| TabAgentSession {
|
|
fork_label,
|
|
session_id: session_id.map(str::to_string),
|
|
remote,
|
|
};
|
|
assert!(session(Some("Fork Session"), Some("abc"), false).forkable());
|
|
assert!(
|
|
!session(None, Some("abc"), false).forkable(),
|
|
"an agent with no fork command is never forkable"
|
|
);
|
|
assert!(
|
|
!session(Some("Fork Session"), None, false).forkable(),
|
|
"no session id yet — the hooks haven't reported one"
|
|
);
|
|
assert!(
|
|
!session(Some("Fork Session"), Some("abc"), true).forkable(),
|
|
"a remote pane would fork the wrong machine's session"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_connecting_pane_saves_the_agent_it_is_rebuilding(cx: &mut gpui::TestAppContext) {
|
|
use crate::core::cli_agent::CLIAgent;
|
|
use crate::core::session::SessionPane;
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
use crate::ui::pending_pane::{PendingPane, PendingSpawn};
|
|
use gpui::AppContext as _;
|
|
|
|
cx.update(|cx| {
|
|
let pending = cx.new(|cx| {
|
|
PendingPane::new(
|
|
"build-box",
|
|
PendingSpawn {
|
|
workspace: None,
|
|
working_directory: Some(std::path::PathBuf::from("/work")),
|
|
restore_pane: Some(7),
|
|
shell: None,
|
|
agent: Some(CLIAgent::Claude),
|
|
agent_session_id: Some("sid-abc".to_string()),
|
|
agent_launch_argv: Some(vec!["claude".to_string()]),
|
|
owner: None,
|
|
font_size: 14.0,
|
|
},
|
|
cx,
|
|
)
|
|
});
|
|
let saved = super::pane_to_session(&Pane::leaf(PaneSlot::Connecting(pending)), cx);
|
|
let SessionPane::Leaf {
|
|
pane_id,
|
|
agent,
|
|
agent_session_id,
|
|
agent_launch_argv,
|
|
..
|
|
} = saved
|
|
else {
|
|
panic!("a leaf saves as a leaf");
|
|
};
|
|
assert_eq!(pane_id, Some(7), "the id it is re-attaching to");
|
|
assert_eq!(agent, Some(CLIAgent::Claude));
|
|
assert_eq!(agent_session_id.as_deref(), Some("sid-abc"));
|
|
assert_eq!(agent_launch_argv, Some(vec!["claude".to_string()]));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn parses_ssh_option_words_with_quotes() {
|
|
assert_eq!(
|
|
parse_ssh_option_words("-p 2222 -J 'jump host' -o \"User=dev\"").unwrap(),
|
|
vec!["-p", "2222", "-J", "jump host", "-o", "User=dev"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unclosed_ssh_option_quote() {
|
|
assert!(parse_ssh_option_words("-J 'jump").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_typed_connect_into_native_profile() {
|
|
let p = parse_ssh_connect_input("ssh deploy@10.0.0.5:2222").unwrap();
|
|
assert_eq!(p.profile.host, "10.0.0.5");
|
|
assert_eq!(p.profile.user, "deploy");
|
|
assert_eq!(p.profile.port, 2222);
|
|
assert!(p.proxy_jump.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_typed_connect_flags_and_jump() {
|
|
let p =
|
|
parse_ssh_connect_input("ssh -p 2222 -l dev -i ~/.ssh/id_ed25519 -J 'jump host' host")
|
|
.unwrap();
|
|
assert_eq!(p.profile.host, "host");
|
|
assert_eq!(p.profile.user, "dev");
|
|
assert_eq!(p.profile.port, 2222);
|
|
assert_eq!(
|
|
p.profile.identity_files,
|
|
vec!["~/.ssh/id_ed25519".to_string()]
|
|
);
|
|
assert_eq!(p.proxy_jump.as_deref(), Some("jump host"));
|
|
|
|
let p = parse_ssh_connect_input("host -p2222 -o User=deploy -o Port=2200").unwrap();
|
|
assert_eq!(p.profile.user, "deploy");
|
|
assert_eq!(p.profile.port, 2200);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_flags_override_target_userhost() {
|
|
let p = parse_ssh_connect_input("ssh me@host:22 -l other -p 2200").unwrap();
|
|
assert_eq!(p.profile.user, "other");
|
|
assert_eq!(p.profile.port, 2200);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bad_typed_connect_lines() {
|
|
assert!(parse_ssh_connect_input("ssh -p 2222").is_err());
|
|
assert!(parse_ssh_connect_input("ssh dev uptime").is_err());
|
|
assert!(parse_ssh_connect_input("ssh -- dev").is_err());
|
|
assert!(parse_ssh_connect_input("ssh 'host").is_err());
|
|
assert!(parse_ssh_connect_input("ssh host -p 0").is_err());
|
|
}
|
|
|
|
fn argv(words: &[&str]) -> Vec<String> {
|
|
words.iter().map(|w| (*w).to_string()).collect()
|
|
}
|
|
|
|
/// The invariant #551 is fixed with: what `build_shell_inputs` writes into
|
|
/// the Arguments field must parse back to exactly the argv it came from, so
|
|
/// that opening Settings and clicking away cannot rewrite `config.json`.
|
|
#[test]
|
|
fn shell_arguments_round_trip_between_field_text_and_argv() {
|
|
for case in [
|
|
&["-l"][..],
|
|
&["-c", "echo hi"],
|
|
// A space-join cannot even spell these three.
|
|
&[""],
|
|
&["don't"],
|
|
&["--say", "it's \"quoted\""],
|
|
// Backslashes are how a path is spelled on Windows, and the field
|
|
// has to be able to carry one either bare or inside quotes.
|
|
&[r"--dir", r"C:\Users\me"],
|
|
&[r"--dir", r"C:\Program Files\tty7"],
|
|
&["--tag", "#1"],
|
|
] {
|
|
let argv = argv(case);
|
|
let field_text = join_shell_args(&argv);
|
|
assert_eq!(
|
|
split_shell_args(&field_text).expect("tty7's own refill must parse"),
|
|
argv,
|
|
"round trip through {field_text:?}"
|
|
);
|
|
}
|
|
// Nothing that does not need quoting acquires any: the everyday field
|
|
// still reads exactly like the user typed it.
|
|
assert_eq!(
|
|
join_shell_args(&argv(&["-l", "--color=auto", r"C:\Users\me", "#1"])),
|
|
r"-l --color=auto C:\Users\me #1"
|
|
);
|
|
assert_eq!(join_shell_args(&[]), "");
|
|
}
|
|
|
|
/// What the user types has to reach argv meaning what it says: quoted text
|
|
/// is one argument, and nothing outside quotes is an escape or a comment.
|
|
#[test]
|
|
fn shell_arguments_split_the_way_the_field_promises() {
|
|
assert_eq!(
|
|
split_shell_args("--login -c \"echo hi\"").expect("balanced quotes parse"),
|
|
argv(&["--login", "-c", "echo hi"])
|
|
);
|
|
assert_eq!(
|
|
split_shell_args("-c 'echo hi'").expect("single quotes parse"),
|
|
argv(&["-c", "echo hi"])
|
|
);
|
|
// A backslash outside quotes is a character, not an escape: there is no
|
|
// shell between this field and argv, and POSIX rules would quietly turn
|
|
// a Windows path into `C:Usersme`.
|
|
assert_eq!(
|
|
split_shell_args(r"--dir C:\Users\me").expect("a bare path parses"),
|
|
argv(&["--dir", r"C:\Users\me"])
|
|
);
|
|
// `#` starts no comment either — swallowing the rest of the line is the
|
|
// silent rewrite this change exists to stop.
|
|
assert_eq!(
|
|
split_shell_args("--tag #1 --verbose").expect("a hash parses"),
|
|
argv(&["--tag", "#1", "--verbose"])
|
|
);
|
|
// Inside double quotes, and only there, `\"` and `\\` stand for
|
|
// themselves — that is how `join_shell_args` spells an argument that
|
|
// carries both kinds of quote.
|
|
assert_eq!(
|
|
split_shell_args("\"it's \\\"quoted\\\"\"").expect("escapes parse"),
|
|
argv(&["it's \"quoted\""])
|
|
);
|
|
assert_eq!(
|
|
split_shell_args("'' -l").expect("an empty argument parses"),
|
|
argv(&["", "-l"])
|
|
);
|
|
}
|
|
|
|
/// The other half of the #551 contract: a value that cannot become argv is
|
|
/// refused, not shredded into fragments that happen to contain quotes.
|
|
#[test]
|
|
fn unbalanced_quotes_are_refused_not_shredded() {
|
|
assert!(split_shell_args("-c \"echo hi").is_err());
|
|
assert!(split_shell_args("--name 'tty7").is_err());
|
|
assert!(split_shell_args("\"a\\\"").is_err());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_window {
|
|
use crate::core::config::Config;
|
|
use crate::core::session::Session;
|
|
use crate::ui::app::Tty7App;
|
|
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
|
|
|
|
pub(crate) fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
|
|
crate::core::config::pin_test_config_dir();
|
|
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
});
|
|
let window = cx.add_window(|window, cx| {
|
|
let app =
|
|
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
|
|
gpui_component::Root::new(app, window, cx)
|
|
});
|
|
window
|
|
.update(cx, |_, window, _| window.activate_window())
|
|
.unwrap();
|
|
cx.background_executor.run_until_parked();
|
|
let app = window
|
|
.update(cx, |root, _, _| {
|
|
root.view()
|
|
.clone()
|
|
.downcast::<Tty7App>()
|
|
.ok()
|
|
.expect("window root wraps a Tty7App")
|
|
})
|
|
.unwrap();
|
|
let vcx = VisualTestContext::from_window(window.into(), cx);
|
|
(app, vcx)
|
|
}
|
|
|
|
/// A window carrying `n` quiet tabs, active on the first.
|
|
pub(crate) fn harness_with_tabs(
|
|
cx: &mut TestAppContext,
|
|
n: usize,
|
|
) -> (
|
|
Entity<Tty7App>,
|
|
VisualTestContext,
|
|
Vec<crate::daemon::transport::Stream>,
|
|
) {
|
|
use crate::terminal::view::quiet_test_pane;
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
|
|
let (app, mut vcx) = harness(cx);
|
|
vcx.update(|_, cx| {
|
|
let mut cfg = cx.global::<Config>().clone();
|
|
cfg.cursor_blink = false;
|
|
cx.set_global(cfg);
|
|
});
|
|
let streams = app.update_in(&mut vcx, |app, window, cx| {
|
|
let mut streams = Vec::new();
|
|
for i in 0..n {
|
|
let (view, stream) = quiet_test_pane(i as u64 + 1, window, cx);
|
|
app.tabs
|
|
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(view))));
|
|
streams.push(stream);
|
|
}
|
|
app.active = 0;
|
|
cx.notify();
|
|
streams
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
(app, vcx, streams)
|
|
}
|
|
|
|
pub(crate) fn harness_with_pane(
|
|
cx: &mut TestAppContext,
|
|
) -> (
|
|
Entity<Tty7App>,
|
|
VisualTestContext,
|
|
crate::daemon::transport::Stream,
|
|
) {
|
|
use crate::terminal::view::quiet_test_pane;
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
|
|
let (app, mut vcx) = harness(cx);
|
|
vcx.update(|_, cx| {
|
|
let mut cfg = cx.global::<Config>().clone();
|
|
cfg.cursor_blink = false;
|
|
cx.set_global(cfg);
|
|
});
|
|
let stream = app.update_in(&mut vcx, |app, window, cx| {
|
|
let (view, stream) = quiet_test_pane(1, window, cx);
|
|
app.tabs
|
|
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(view))));
|
|
app.active = 0;
|
|
cx.notify();
|
|
stream
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
(app, vcx, stream)
|
|
}
|
|
|
|
/// Wait until the window has actually stopped drawing — which is not the
|
|
/// same as having reached the state a test was waiting for.
|
|
///
|
|
/// Called both after a settle and at the top of `draws_while_idle`: a test
|
|
/// that reaches its state, asserts a few things about it and only then
|
|
/// measures has given the setup more time to land, but not necessarily
|
|
/// enough, and the measurement is the place that cannot afford to be
|
|
/// wrong.
|
|
///
|
|
/// Both of a `render_idle` test's clocks have to be pumped here, and they
|
|
/// are pumped differently.
|
|
///
|
|
/// The pane runs its own git pipeline, separate from whatever panel is on
|
|
/// screen, and it hangs off a 300ms timer on the *virtual* clock — so it
|
|
/// never starts at all unless a test advances that clock. It used to be
|
|
/// `draws_while_idle`'s own `advance_clock` that started it, which put the
|
|
/// pane's first real `git` run, and the repaint it lands with, inside the
|
|
/// window being counted. Whether that repaint arrived before or after the
|
|
/// count then came down to how fast git ran, which is why these tests were
|
|
/// green here and red on a loaded CI runner (issue #523).
|
|
///
|
|
/// What that repaint sets off in turn is timed on the *real* clock: the
|
|
/// landing opens a `GIT_WATCH_DEBOUNCE` burst, and closing the burst costs
|
|
/// another frame 250ms later. So the sleep below is load-bearing too, and
|
|
/// a round that drew nothing is not on its own enough to stop on — a burst
|
|
/// still open is a frame already owed.
|
|
pub(crate) fn quiesce(vcx: &mut VisualTestContext, cwd: Option<&std::path::Path>) {
|
|
use crate::terminal::git_data::ScmData;
|
|
use crate::terminal::git_status::GitStatusCache;
|
|
use crate::ui::app::render_probe;
|
|
use crate::ui::host_ops::HostId;
|
|
|
|
/// How long quiet has to hold before it counts as quiet.
|
|
///
|
|
/// Real time, and the only defence against the third clock in play:
|
|
/// the kernel's. The file tree keeps a real `inotify`/`FSEvents`
|
|
/// watch, and the writes a test makes while setting up its repository
|
|
/// are still being delivered long after every future the test can wait
|
|
/// on has resolved. They arrive on the channel, sit in a 200ms debounce
|
|
/// on the virtual clock, and are released by the next `advance_clock`
|
|
/// — which, without this, was the measurement's own. Any delivery
|
|
/// restarts the hold, so the wait is as long as the runner needs and
|
|
/// no longer.
|
|
const QUIET_HOLD: std::time::Duration = std::time::Duration::from_millis(400);
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let mut quiet_since: Option<std::time::Instant> = None;
|
|
loop {
|
|
render_probe::arm(u64::MAX);
|
|
vcx.executor()
|
|
.advance_clock(std::time::Duration::from_millis(300));
|
|
vcx.background_executor.run_until_parked();
|
|
let quiet = render_probe::draws() == 0
|
|
&& vcx.update(|_, cx| {
|
|
let owed = cx
|
|
.try_global::<ScmData>()
|
|
.is_some_and(ScmData::is_debouncing);
|
|
let answered = cwd.is_none_or(|cwd| {
|
|
cx.try_global::<GitStatusCache>()
|
|
.and_then(|cache| cache.known_repo_for(HostId::LOCAL, cwd))
|
|
.is_some()
|
|
});
|
|
!owed && answered
|
|
});
|
|
match quiet {
|
|
false => quiet_since = None,
|
|
true => {
|
|
let since = *quiet_since.get_or_insert_with(std::time::Instant::now);
|
|
if since.elapsed() >= QUIET_HOLD {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"the window never stopped drawing"
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod cursor_blink_gpui_tests {
|
|
use super::test_window::harness;
|
|
use crate::core::config::Config;
|
|
use crate::terminal::view::quiet_test_pane;
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
use gpui::TestAppContext;
|
|
|
|
#[gpui::test]
|
|
fn only_the_focused_pane_advances_its_blink_phase(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
vcx.update(|_, cx| {
|
|
let mut cfg = cx.global::<Config>().clone();
|
|
cfg.cursor_blink = true;
|
|
cx.set_global(cfg);
|
|
});
|
|
|
|
let (left, right, _streams) = app.update_in(&mut vcx, |app, window, cx| {
|
|
let (left, left_stream) = quiet_test_pane(1, window, cx);
|
|
let (right, right_stream) = quiet_test_pane(2, window, cx);
|
|
app.tabs.push(super::Tab::new(Pane::split_node(
|
|
gpui::Axis::Horizontal,
|
|
0.5,
|
|
Pane::leaf(PaneSlot::Ready(left.clone())),
|
|
Pane::leaf(PaneSlot::Ready(right.clone())),
|
|
)));
|
|
app.active = 0;
|
|
let left_focus = left.read(cx).focus_handle.clone();
|
|
left_focus.focus(window, cx);
|
|
cx.notify();
|
|
(left, right, (left_stream, right_stream))
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |_, window, cx| {
|
|
assert!(left.read(cx).focus_handle.is_focused(window));
|
|
assert!(!right.read(cx).focus_handle.is_focused(window));
|
|
assert!(left.read(cx).cursor_visible);
|
|
assert!(right.read(cx).cursor_visible);
|
|
});
|
|
|
|
vcx.executor()
|
|
.advance_clock(std::time::Duration::from_millis(530));
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update(&mut vcx, |_, cx| {
|
|
assert!(!left.read(cx).cursor_visible, "the focused cursor blinks");
|
|
assert!(
|
|
right.read(cx).cursor_visible,
|
|
"the unfocused cursor must keep a steady phase"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ssh_rebuild_gpui_tests {
|
|
use super::test_window::harness_with_pane;
|
|
use crate::core::session::{
|
|
RemoteRef, RemoteTarget, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
|
|
};
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
use gpui::TestAppContext;
|
|
use tty7_core::core::machine::{LayoutDelta, PaneNode, Tab as TreeTab};
|
|
|
|
#[gpui::test]
|
|
fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
|
|
|
|
let remote = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Alias {
|
|
alias: "build-box".into(),
|
|
},
|
|
WorkspaceId::new(),
|
|
));
|
|
let remote_id = remote.id;
|
|
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![remote],
|
|
active: None,
|
|
},
|
|
);
|
|
app.workspace = remote_id;
|
|
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
|
|
let existing = std::mem::replace(&mut app.tabs[0].pane, Pane::Empty);
|
|
app.tabs[0].pane = Pane::split_node(
|
|
gpui::Axis::Horizontal,
|
|
0.5,
|
|
existing,
|
|
Pane::leaf(PaneSlot::Ready(ssh_view)),
|
|
);
|
|
stream
|
|
});
|
|
|
|
let applied = app.update_in(&mut vcx, |app, window, cx| {
|
|
let tab = TreeTab {
|
|
id: app.tabs[0].tree_id.get(),
|
|
name: None,
|
|
sidebar_group: None,
|
|
root: PaneNode::Leaf { pane: 1 },
|
|
};
|
|
app.apply_layout_delta(
|
|
&LayoutDelta::TabRestructured { tab, pane: None },
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
assert!(
|
|
applied,
|
|
"the delta must apply without falling back to a resync"
|
|
);
|
|
|
|
app.update_in(&mut vcx, |app, _, cx| {
|
|
let leaves = app.tabs[0].pane.leaves();
|
|
assert_eq!(leaves.len(), 2, "the ssh split must survive the rebuild");
|
|
assert!(
|
|
leaves.iter().any(|slot| match slot {
|
|
PaneSlot::Ready(view) => view.read(cx).ssh_spec().is_some(),
|
|
_ => false,
|
|
}),
|
|
"one leaf is still the native-SSH pane"
|
|
);
|
|
assert!(
|
|
leaves.iter().any(|slot| match slot {
|
|
PaneSlot::Ready(view) => {
|
|
let view = view.read(cx);
|
|
view.ssh_spec().is_none() && view.pane_id == 1
|
|
}
|
|
_ => false,
|
|
}),
|
|
"the remote pane's existing view is reused, not re-attached"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
|
|
|
|
let remote = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Alias {
|
|
alias: "build-box".into(),
|
|
},
|
|
WorkspaceId::new(),
|
|
));
|
|
let remote_id = remote.id;
|
|
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![remote],
|
|
active: None,
|
|
},
|
|
);
|
|
app.workspace = remote_id;
|
|
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
|
|
app.tabs
|
|
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view))));
|
|
stream
|
|
});
|
|
|
|
let (desired, _active, held) = app.update_in(&mut vcx, |app, _, cx| {
|
|
crate::ui::tree_sync::desired_tabs(app, cx)
|
|
});
|
|
assert_eq!(
|
|
desired.len(),
|
|
1,
|
|
"only the remote-backed tab can be named in the machine's tree"
|
|
);
|
|
assert!(
|
|
held.is_empty(),
|
|
"the pure-SSH tab is permanently invisible, not held — holding it \
|
|
would freeze ordering and active-tab sync for the whole window"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod keybinding_gpui_tests {
|
|
use super::test_window::harness;
|
|
use crate::core::config::Config;
|
|
use crate::ui::app::Tty7App;
|
|
use crate::ui::settings::SettingsSection;
|
|
use gpui::{Entity, TestAppContext, VisualTestContext};
|
|
|
|
fn begin_capture(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, action: &str) {
|
|
let action = action.to_string();
|
|
app.update_in(vcx, |app, window, cx| {
|
|
app.toggle_settings(window, cx);
|
|
app.select_settings_section(SettingsSection::Keybindings, cx);
|
|
app.start_recording_key(action, window, cx);
|
|
});
|
|
}
|
|
|
|
/// Waits for `action`'s entry in config to read `expected` — compared as the
|
|
/// JSON the file gets, since that is what the reader of `config.json` sees.
|
|
fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: serde_json::Value) {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
loop {
|
|
vcx.background_executor.run_until_parked();
|
|
let got = vcx.update(|_, cx| {
|
|
serde_json::to_value(cx.global::<Config>().keybindings.get(action))
|
|
.expect("a binding serializes")
|
|
});
|
|
if got == expected {
|
|
return;
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"binding for {action} never became {expected:?} (last {got:?})"
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn recording_a_shortcut_writes_the_override_and_ends_capture(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
begin_capture(&app, &mut vcx, "NewTab");
|
|
vcx.simulate_keystrokes("secondary-shift-n");
|
|
// A list, because recording a shortcut on the Settings page *sets* it:
|
|
// the row showed one chord and now shows another. A bare string in
|
|
// config adds a chord beside the default (#868), which is not what
|
|
// the person at the row just did.
|
|
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-shift-n"]));
|
|
|
|
let recording = app.update_in(&mut vcx, |app, _, _| {
|
|
app.active_settings().map(|s| s.recording.is_some())
|
|
});
|
|
assert_eq!(
|
|
recording,
|
|
Some(false),
|
|
"capture should end after committing"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn recording_a_two_chord_sequence_writes_the_full_spec(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
begin_capture(&app, &mut vcx, "CloseActiveTab");
|
|
vcx.simulate_keystrokes("secondary-b");
|
|
vcx.simulate_keystrokes("x");
|
|
wait_for_binding(
|
|
&mut vcx,
|
|
"CloseActiveTab",
|
|
serde_json::json!(["secondary-b x"]),
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn recording_a_chord_another_action_also_has_takes_only_that_chord(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
vcx.update(|_, cx| {
|
|
cx.global_mut::<Config>().keybindings = serde_json::from_value(serde_json::json!({
|
|
"NextTab": ["ctrl-tab", "secondary-alt-n"],
|
|
}))
|
|
.expect("the binding loads");
|
|
crate::ui::keymap::rebind(cx);
|
|
});
|
|
begin_capture(&app, &mut vcx, "NewTab");
|
|
vcx.simulate_keystrokes("secondary-alt-n");
|
|
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-alt-n"]));
|
|
// Emptying the other action was right when an action had one chord.
|
|
// With two, it would take Ctrl+Tab away as well, for a keystroke that
|
|
// was never on it.
|
|
wait_for_binding(&mut vcx, "NextTab", serde_json::json!(["ctrl-tab"]));
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn recording_an_extra_default_chord_displaces_its_owner(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
begin_capture(&app, &mut vcx, "NewTab");
|
|
vcx.simulate_keystrokes("alt-enter");
|
|
wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["alt-enter"]));
|
|
wait_for_binding(&mut vcx, "InsertNewline", serde_json::json!([]));
|
|
|
|
let note = app.update_in(&mut vcx, |app, _, _| {
|
|
app.active_settings().and_then(|s| s.rebinding_note.clone())
|
|
});
|
|
assert!(
|
|
note.as_deref()
|
|
.is_some_and(|n| n.contains("Insert Newline")),
|
|
"the takeover note must name the action that lost the chord (got {note:?})"
|
|
);
|
|
}
|
|
|
|
/// #901, the half that happens in the UI: Alt+1…9 belongs to vim, and the
|
|
/// only gesture in the app that looks like "take this shortcut away" used
|
|
/// to *reset* the row instead — a no-op on a row nobody had overridden,
|
|
/// so the default appeared to restore itself however many times it was
|
|
/// pressed.
|
|
#[gpui::test]
|
|
fn backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
let (app, mut vcx) = harness(cx);
|
|
let shipped = vcx
|
|
.update(|_, cx| crate::ui::keymap::effective_key("ActivateTab1", cx))
|
|
.expect("Go to Tab 1 ships with a chord");
|
|
|
|
begin_capture(&app, &mut vcx, "ActivateTab1");
|
|
vcx.simulate_keystrokes("backspace");
|
|
wait_for_binding(&mut vcx, "ActivateTab1", serde_json::json!([]));
|
|
|
|
vcx.update(|_, cx| {
|
|
assert_eq!(
|
|
crate::ui::keymap::effective_key("ActivateTab1", cx),
|
|
None,
|
|
"the row has no chord left to show"
|
|
);
|
|
let typed = [gpui::Keystroke::parse(&shipped).expect("the chord parses")];
|
|
let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")];
|
|
assert!(
|
|
cx.key_bindings()
|
|
.borrow()
|
|
.bindings_for_input(&typed, &context)
|
|
.0
|
|
.is_empty(),
|
|
"{shipped} must reach the terminal now, not the tab switcher"
|
|
);
|
|
});
|
|
|
|
// Reversible, and by the button that is already on the row: an
|
|
// overridden action — unbound counts — shows **Reset**.
|
|
app.update_in(&mut vcx, |app, _, cx| {
|
|
app.reset_keybinding("ActivateTab1".to_string(), cx)
|
|
});
|
|
vcx.update(|_, cx| {
|
|
assert_eq!(
|
|
crate::ui::keymap::effective_key("ActivateTab1", cx).as_deref(),
|
|
Some(shipped.as_str()),
|
|
"Reset is the way back to the shipped chord"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.toggle_settings(window, cx);
|
|
app.select_settings_section(SettingsSection::Keybindings, cx);
|
|
app.start_recording_key("NewTab".to_string(), window, cx);
|
|
});
|
|
vcx.simulate_keystrokes("escape");
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
let stored = vcx.update(|_, cx| cx.global::<Config>().keybindings.contains_key("NewTab"));
|
|
assert!(!stored, "Esc must not persist a binding");
|
|
let recording = app.update_in(&mut vcx, |app, _, _| {
|
|
app.active_settings().map(|s| s.recording.is_some())
|
|
});
|
|
assert_eq!(recording, Some(false));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ui_font_gpui_tests {
|
|
use super::test_window::harness;
|
|
use crate::core::config::Config;
|
|
use crate::ui::settings::ui_font_default_label;
|
|
use gpui::TestAppContext;
|
|
use gpui_component::Theme;
|
|
|
|
/// Picking a face and picking Default back are the same rule read in both
|
|
/// directions. Only one of them used to be spelled: the chrome took the
|
|
/// pick, but clearing it wrote `None` to `config.json` and left the window
|
|
/// in the old face until the next launch — a saved setting that looked
|
|
/// like it had applied instantly and had not.
|
|
#[gpui::test]
|
|
fn clearing_the_interface_font_puts_the_stock_face_back(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
let stock = vcx.update(|_, cx| Theme::global(cx).font_family.clone());
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.commit_ui_font_family("Courier".to_string(), window, cx);
|
|
});
|
|
vcx.update(|_, cx| {
|
|
assert_eq!(
|
|
cx.global::<Config>().ui_font_family.as_deref(),
|
|
Some("Courier")
|
|
);
|
|
assert_eq!(Theme::global(cx).font_family.as_ref(), "Courier");
|
|
});
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.commit_ui_font_family(ui_font_default_label().to_string(), window, cx);
|
|
});
|
|
vcx.update(|_, cx| {
|
|
assert_eq!(cx.global::<Config>().ui_font_family, None);
|
|
assert_eq!(
|
|
Theme::global(cx).font_family,
|
|
stock,
|
|
"Default has to hand the interface back to the system face"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod restart_server_gpui_tests {
|
|
use crate::core::config::Config;
|
|
use crate::core::session::Session;
|
|
use crate::ui::app::Tty7App;
|
|
use gpui::{AppContext, TestAppContext};
|
|
|
|
/// Clicking Restart Server made the whole app disappear.
|
|
///
|
|
/// The work that puts the window back together after the restart ends by
|
|
/// rebuilding every local window from the machine tree, and the first thing
|
|
/// that rebuild asks each window is which tabs it is showing — which it
|
|
/// reads back out of the window registry. Run from inside `update_in` on
|
|
/// this window's own entity, the first window it reaches for is the one the
|
|
/// closure already holds leased, and gpui answers a double lease by
|
|
/// panicking, which on the main thread is the process.
|
|
///
|
|
/// Driven through `settle_after_restart` with a restart that "succeeded",
|
|
/// because the crash is in the part that runs either way, not in the
|
|
/// restart itself.
|
|
#[gpui::test]
|
|
async fn settling_after_a_restart_does_not_lease_the_window_twice(cx: &mut TestAppContext) {
|
|
crate::core::config::pin_test_config_dir();
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
crate::ui::windows::WindowRegistry::init(cx);
|
|
});
|
|
let window = cx.add_window(|window, cx| {
|
|
let app =
|
|
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
|
|
gpui_component::Root::new(app, window, cx)
|
|
});
|
|
let app = window
|
|
.update(cx, |root, _, _| {
|
|
root.view()
|
|
.clone()
|
|
.downcast::<Tty7App>()
|
|
.ok()
|
|
.expect("window root wraps a Tty7App")
|
|
})
|
|
.unwrap();
|
|
|
|
// Registered the way an opened window registers itself: without this
|
|
// the rebuild finds no window to ask and never reaches for the entity,
|
|
// which is the whole thing under test.
|
|
let handle = window.into();
|
|
let weak = app.downgrade();
|
|
app.update(cx, |app, cx| {
|
|
crate::ui::windows::WindowRegistry::register(cx, app.workspace, handle, weak);
|
|
});
|
|
|
|
Tty7App::settle_after_restart(app.downgrade(), Ok(()), &mut cx.to_async()).await;
|
|
|
|
assert!(
|
|
app.update(cx, |app, _| app.startup_error.is_none()),
|
|
"a restart reported as successful must not leave an error banner"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod shell_menu_gpui_tests {
|
|
use crate::core::config::Config;
|
|
use crate::core::session::{
|
|
RemoteRef, RemoteTarget, Session, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
|
|
};
|
|
use crate::ui::app::Tty7App;
|
|
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
|
|
|
|
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
|
|
crate::core::config::pin_test_config_dir();
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
crate::ui::windows::WindowRegistry::init(cx);
|
|
});
|
|
let window = cx.add_window(|window, cx| {
|
|
let app =
|
|
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
|
|
gpui_component::Root::new(app, window, cx)
|
|
});
|
|
let app = window
|
|
.update(cx, |root, _, _| {
|
|
root.view()
|
|
.clone()
|
|
.downcast::<Tty7App>()
|
|
.ok()
|
|
.expect("window root wraps a Tty7App")
|
|
})
|
|
.unwrap();
|
|
let vcx = VisualTestContext::from_window(window.into(), cx);
|
|
(app, vcx)
|
|
}
|
|
|
|
fn pump_until(
|
|
app: &Entity<Tty7App>,
|
|
vcx: &mut VisualTestContext,
|
|
done: impl Fn(&Tty7App) -> bool,
|
|
) -> bool {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
loop {
|
|
vcx.background_executor.run_until_parked();
|
|
if app.update(vcx, |app, _| done(app)) {
|
|
return true;
|
|
}
|
|
if std::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_local_window_lists_this_computers_shells(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
assert!(
|
|
pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()),
|
|
"the local probe never landed"
|
|
);
|
|
app.update(&mut vcx, |app, _| {
|
|
assert!(app.shells_host.is_local());
|
|
assert!(
|
|
!app.shells.default_name.is_empty(),
|
|
"the menu has no default to tag"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn an_unreachable_remote_window_offers_no_local_shells(cx: &mut TestAppContext) {
|
|
let (app, mut vcx) = harness(cx);
|
|
assert!(
|
|
pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()),
|
|
"the local probe never landed"
|
|
);
|
|
|
|
let remote = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Alias {
|
|
alias: "build-box".into(),
|
|
},
|
|
WorkspaceId::new(),
|
|
));
|
|
let remote_id = remote.id;
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![remote],
|
|
active: None,
|
|
},
|
|
);
|
|
app.switch_workspace(Some(remote_id), window, cx);
|
|
});
|
|
|
|
assert!(
|
|
pump_until(&app, &mut vcx, |app| !app.shells_host.is_local()),
|
|
"the window never rebound to the remote machine"
|
|
);
|
|
app.update(&mut vcx, |app, _| {
|
|
assert!(
|
|
app.shells.shells.is_empty(),
|
|
"a remote window must not offer this computer's shells: {:?}",
|
|
app.shells.shells
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod rename_gpui_tests {
|
|
use gpui::TestAppContext;
|
|
|
|
use crate::ui::app::test_window::harness_with_tabs;
|
|
|
|
#[gpui::test]
|
|
fn a_rename_box_opens_with_the_caret_after_the_name(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| app.start_rename(0, window, cx));
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update(&mut vcx, |app, cx| {
|
|
let input = app
|
|
.renaming
|
|
.as_ref()
|
|
.expect("the rename box is up")
|
|
.input
|
|
.clone();
|
|
let state = input.read(cx);
|
|
let value = state.value().to_string();
|
|
assert!(!value.is_empty(), "the box starts on the current name");
|
|
let end = value.len();
|
|
assert_eq!(
|
|
state.selected_range(),
|
|
end..end,
|
|
"typing has to continue {value:?}, not land in front of it"
|
|
);
|
|
});
|
|
}
|
|
#[gpui::test]
|
|
fn a_rename_rides_out_other_tabs_closing_and_the_strip_reordering(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.start_rename(2, window, cx);
|
|
let target = app.tabs[2].tree_id.get();
|
|
let input = app.renaming.as_ref().expect("the box is up").input.clone();
|
|
input.update(cx, |s, cx| s.set_value("mine", window, cx));
|
|
|
|
// An unrelated close used to throw the half-typed name away
|
|
// (#598).
|
|
app.close_tab_inner(0, true, window, cx);
|
|
assert!(
|
|
app.renaming.is_some(),
|
|
"closing another tab keeps the rename box"
|
|
);
|
|
|
|
// So did a drag-reorder — and the index the commit once landed
|
|
// on had by then drifted onto a different tab.
|
|
let order: Vec<usize> = (0..app.tabs.len()).rev().collect();
|
|
app.apply_tab_order(&order, cx);
|
|
assert!(app.renaming.is_some(), "a reorder keeps the rename box");
|
|
|
|
app.commit_rename(window, cx);
|
|
let named: Vec<_> = app
|
|
.tabs
|
|
.iter()
|
|
.filter(|t| t.name.as_deref() == Some("mine"))
|
|
.collect();
|
|
assert_eq!(named.len(), 1, "the name landed on exactly one tab");
|
|
assert_eq!(
|
|
named[0].tree_id.get(),
|
|
target,
|
|
"and that tab is the one the box was opened on"
|
|
);
|
|
});
|
|
|
|
// Closing the renaming tab itself still ends the rename.
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.start_rename(0, window, cx);
|
|
assert!(app.renaming.is_some());
|
|
app.close_tab_inner(0, true, window, cx);
|
|
assert!(app.renaming.is_none(), "losing its own tab closes the box");
|
|
});
|
|
}
|
|
}
|
|
|
|
// The output gate: a pane repaints on PTY output only while it is on screen.
|
|
// `Tty7App::render` declares the active tab's panes displayed each frame and
|
|
// everything else hidden; a pane nobody has declared — or whose id nobody
|
|
// registered — must err toward displayed, because the failure direction that
|
|
// matters is a visible pane that stops repainting.
|
|
#[cfg(test)]
|
|
mod displayed_gpui_tests {
|
|
use gpui::TestAppContext;
|
|
|
|
use crate::terminal::view::{declare_displayed, displayed_for_test, quiet_test_pane};
|
|
use crate::ui::app::test_window::harness_with_tabs;
|
|
|
|
fn pane_id(app: &super::Tty7App, tab: usize) -> gpui::EntityId {
|
|
app.tabs[tab]
|
|
.pane
|
|
.first_leaf()
|
|
.expect("tab has a pane")
|
|
.entity_id()
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn the_output_gate_follows_the_active_tab(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
let (front, back) = (pane_id(app, 0), pane_id(app, 1));
|
|
|
|
app.declare_displayed_panes(cx);
|
|
assert_eq!(
|
|
displayed_for_test(cx, front),
|
|
Some(true),
|
|
"the active tab's pane repaints on output"
|
|
);
|
|
assert_eq!(
|
|
displayed_for_test(cx, back),
|
|
Some(false),
|
|
"a background tab's pane stays out of the frame loop"
|
|
);
|
|
|
|
app.activate(1, window, cx);
|
|
app.declare_displayed_panes(cx);
|
|
assert_eq!(displayed_for_test(cx, front), Some(false));
|
|
assert_eq!(
|
|
displayed_for_test(cx, back),
|
|
Some(true),
|
|
"switching tabs hands the frame loop to the new tab"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_pane_nobody_declared_counts_as_displayed(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
|
|
|
let _orphan = app.update_in(&mut vcx, |app, window, cx| {
|
|
// A pane that exists but sits in no tab — the shape of every
|
|
// "path that never declares": it registers as displayed and a
|
|
// declaration pass over the app's own tabs leaves it alone.
|
|
let (view, stream) = quiet_test_pane(99, window, cx);
|
|
let orphan = view.entity_id();
|
|
assert_eq!(
|
|
displayed_for_test(cx, orphan),
|
|
Some(true),
|
|
"a fresh pane defaults to displayed"
|
|
);
|
|
|
|
app.declare_displayed_panes(cx);
|
|
assert_eq!(
|
|
displayed_for_test(cx, orphan),
|
|
Some(true),
|
|
"declaring only speaks about panes the app holds"
|
|
);
|
|
|
|
// An id nobody registered is declared into the void, not
|
|
// inserted: the registry only ever holds live panes' flags.
|
|
let ghost = gpui::EntityId::from(u64::MAX);
|
|
declare_displayed(cx, [(ghost, false)]);
|
|
assert_eq!(displayed_for_test(cx, ghost), None);
|
|
|
|
(view, stream)
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_released_pane_leaves_the_registry(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
|
|
|
let back = app.update_in(&mut vcx, |app, _window, _cx| {
|
|
let back = pane_id(app, 1);
|
|
app.tabs.remove(1);
|
|
back
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |_, _, cx| {
|
|
assert_eq!(
|
|
displayed_for_test(cx, back),
|
|
None,
|
|
"a closed pane's flag does not outlive it"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Zoom is a tab's view state: it rides with the tab across a switch, while a
|
|
// layout change (drag, split, close) still clears it.
|
|
#[cfg(test)]
|
|
mod zoom_gpui_tests {
|
|
use gpui::TestAppContext;
|
|
|
|
use crate::ui::app::test_window::harness_with_tabs;
|
|
|
|
#[gpui::test]
|
|
fn a_tabs_zoom_survives_a_round_trip_to_another_tab(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
let leaf = app.tabs[0]
|
|
.pane
|
|
.first_leaf()
|
|
.and_then(|slot| slot.terminal().cloned())
|
|
.expect("tab 0 has a pane");
|
|
app.maximized = Some(leaf.clone());
|
|
|
|
app.activate(1, window, cx);
|
|
assert!(app.maximized.is_none(), "tab 1 never zoomed anything");
|
|
|
|
app.activate(0, window, cx);
|
|
assert_eq!(
|
|
app.maximized.as_ref().map(|l| l.entity_id()),
|
|
Some(leaf.entity_id()),
|
|
"tab 0's zoom is still where it was left (#599)"
|
|
);
|
|
|
|
// A zoom stashed for a pane that is no longer in the tab does not
|
|
// come back — it exited (or was closed) while the tab was away.
|
|
app.maximized = Some(leaf.clone());
|
|
app.activate(1, window, cx);
|
|
app.tabs[0].zoomed = Some(leaf);
|
|
app.tabs[0].pane =
|
|
crate::ui::pane::Pane::leaf(app.tabs[1].pane.first_leaf().expect("a donor leaf"));
|
|
app.activate(0, window, cx);
|
|
assert!(
|
|
app.maximized.is_none(),
|
|
"a stashed zoom whose pane is gone stays gone"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// The mark the chrome wears (#752) has to go out again by every road the
|
|
/// zoom itself leaves by, and it has to name the right tab while several
|
|
/// tabs are each holding one.
|
|
#[gpui::test]
|
|
fn the_zoom_mark_is_on_whichever_tabs_are_hiding_panes(cx: &mut TestAppContext) {
|
|
use crate::terminal::view::quiet_test_pane;
|
|
use crate::ui::pane::{Pane, PaneSlot};
|
|
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
// A zoom only hides something where there is a sibling to hide, so
|
|
// tabs 0 and 1 get a second pane and tab 2 stays single.
|
|
let mut held = Vec::new();
|
|
for tab in 0..2 {
|
|
let (view, stream) = quiet_test_pane(90 + tab as u64, window, cx);
|
|
held.push(stream);
|
|
let first = app.tabs[tab].pane.first_leaf().expect("tab has a pane");
|
|
app.tabs[tab].pane = Pane::split_node(
|
|
gpui::Axis::Horizontal,
|
|
0.5,
|
|
Pane::leaf(first),
|
|
Pane::leaf(PaneSlot::Ready(view)),
|
|
);
|
|
}
|
|
|
|
for i in 0..app.tabs.len() {
|
|
assert!(!app.tab_is_zoomed(i), "nothing is zoomed yet");
|
|
}
|
|
|
|
// Zooming marks the tab it happened in, and only that one.
|
|
app.toggle_maximize(window, cx);
|
|
assert!(app.tab_is_zoomed(0), "the zoomed tab wears the mark");
|
|
assert!(!app.tab_is_zoomed(1));
|
|
assert!(!app.tab_is_zoomed(2));
|
|
|
|
// And un-zooming takes it away again.
|
|
app.toggle_maximize(window, cx);
|
|
assert!(!app.tab_is_zoomed(0), "un-zooming clears the mark");
|
|
|
|
// The mark rides with its tab across a switch (#599) — an inactive
|
|
// tab holding a zoom still wears it — and two tabs can wear one at
|
|
// the same time, each reading its own handle.
|
|
app.toggle_maximize(window, cx);
|
|
app.activate(1, window, cx);
|
|
assert!(app.tab_is_zoomed(0), "the parked zoom is still a zoom");
|
|
assert!(!app.tab_is_zoomed(1));
|
|
app.toggle_maximize(window, cx);
|
|
assert!(app.tab_is_zoomed(0) && app.tab_is_zoomed(1));
|
|
assert!(!app.tab_is_zoomed(2), "a single-pane tab hides nothing");
|
|
|
|
// A parked zoom naming a pane that has since left the tab is no
|
|
// zoom: it would not come back on a switch, so it is not marked.
|
|
let parked = app.tabs[0].zoomed.clone().expect("tab 0 parked a zoom");
|
|
let elsewhere = app.tabs[2]
|
|
.pane
|
|
.first_leaf()
|
|
.and_then(|slot| slot.terminal().cloned());
|
|
app.tabs[0].zoomed = elsewhere;
|
|
assert!(
|
|
!app.tab_is_zoomed(0),
|
|
"a zoom over a pane this tab does not hold is not marked"
|
|
);
|
|
app.tabs[0].zoomed = Some(parked.clone());
|
|
assert!(app.tab_is_zoomed(0));
|
|
|
|
// Nor is a zoom over the last pane standing: its siblings closed
|
|
// while the tab was away, and the tab now looks like — and draws
|
|
// as — an ordinary single pane.
|
|
app.tabs[0].pane = Pane::leaf(PaneSlot::Ready(parked));
|
|
assert!(
|
|
!app.tab_is_zoomed(0),
|
|
"a zoom that covers nothing stops being marked"
|
|
);
|
|
|
|
assert!(!app.tab_is_zoomed(9), "there is no tab 9 to mark");
|
|
drop(held);
|
|
});
|
|
}
|
|
}
|
|
|
|
// A test window has no daemon behind it — its socket path is under the pinned
|
|
// test config dir and nothing is listening on it — so every forward request
|
|
// fails. That is exactly the case these are about: what the panel and the form
|
|
// are left holding when the far side does not answer.
|
|
#[cfg(test)]
|
|
mod managed_forward_gpui_tests {
|
|
use gpui::{Focusable as _, TestAppContext};
|
|
use gpui_component::input::InputState;
|
|
|
|
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
|
|
use crate::ui::app::test_window::harness_with_tabs;
|
|
|
|
fn listening(id: u64) -> ManagedForward {
|
|
ManagedForward {
|
|
id,
|
|
pane_id: 1,
|
|
kind: SshForwardKind::Local,
|
|
bind_host: "127.0.0.1".to_string(),
|
|
bind_port: 8080,
|
|
target_host: "10.0.0.5".to_string(),
|
|
target_port: 80,
|
|
description: None,
|
|
status: ForwardStatus::Listening,
|
|
}
|
|
}
|
|
|
|
/// The form had no keyboard contract at all: no Return, no Escape, and it
|
|
/// opened cold, with the caret still in the terminal behind it. Every
|
|
/// sibling form in the app has all three.
|
|
///
|
|
/// Escape is a `on_key_down` on the form itself and only fires while
|
|
/// something inside it holds focus, so the focus below is what makes both
|
|
/// halves work; the subscriptions are what answer Return. Asserting on
|
|
/// both together is the point — arming one without the other is the state
|
|
/// this test exists to catch.
|
|
#[gpui::test]
|
|
fn opening_the_forward_form_arms_the_keyboard_and_closing_disarms_it(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
assert!(
|
|
app.loopback_panel.mf_subs.is_empty(),
|
|
"nothing is listening before the form is up"
|
|
);
|
|
|
|
app.toggle_managed_forward_form(1, window, cx);
|
|
|
|
assert_eq!(
|
|
app.loopback_panel.form_pane_id,
|
|
Some(1),
|
|
"the form is up for the pane that asked"
|
|
);
|
|
assert_eq!(
|
|
app.loopback_panel.mf_subs.len(),
|
|
5,
|
|
"Return has to be answered on every box, not just the first"
|
|
);
|
|
assert!(
|
|
app.loopback_panel
|
|
.mf_bind_host
|
|
.read(cx)
|
|
.focus_handle(cx)
|
|
.is_focused(window),
|
|
"the form opens with the caret in Bind, so Escape reaches it too"
|
|
);
|
|
|
|
app.close_managed_forward_form(window, cx);
|
|
|
|
assert_eq!(app.loopback_panel.form_pane_id, None);
|
|
assert!(
|
|
app.loopback_panel.mf_subs.is_empty(),
|
|
"a live subscription on a box nothing is showing would answer \
|
|
Return for a form that is gone"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn an_add_that_never_reaches_the_session_leaves_the_panel_as_it_was(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.loopback_panel.managed = vec![listening(1)];
|
|
app.loopback_panel.form_pane_id = Some(1);
|
|
// These three fields are the long form's; without this the short
|
|
// form would read them as the one number it asks for.
|
|
app.loopback_panel.mf_advanced = true;
|
|
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
|
|
(&app.loopback_panel.mf_bind_port, "9000"),
|
|
(&app.loopback_panel.mf_target_host, "127.0.0.1"),
|
|
(&app.loopback_panel.mf_target_port, "22"),
|
|
];
|
|
for (input, value) in typed {
|
|
input.update(cx, |input, cx| input.set_value(value, window, cx));
|
|
}
|
|
|
|
app.add_managed_forward(1, window, cx);
|
|
|
|
assert_eq!(
|
|
app.loopback_panel.managed.len(),
|
|
1,
|
|
"a request that failed says nothing about the forwards that are up"
|
|
);
|
|
assert!(
|
|
app.loopback_panel.mf_error.is_some(),
|
|
"and the form has to say why the Add did nothing"
|
|
);
|
|
assert_eq!(
|
|
app.loopback_panel.form_pane_id,
|
|
Some(1),
|
|
"the form stays open on what was typed"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_save_that_cannot_be_made_leaves_the_rule_it_would_replace_alone(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.loopback_panel.managed = vec![listening(1)];
|
|
app.loopback_panel.form_pane_id = Some(1);
|
|
app.loopback_panel.mf_editing = Some(listening(1));
|
|
app.loopback_panel.mf_advanced = true;
|
|
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
|
|
(&app.loopback_panel.mf_bind_port, "8080"),
|
|
(&app.loopback_panel.mf_target_host, "10.0.0.6"),
|
|
(&app.loopback_panel.mf_target_port, "80"),
|
|
];
|
|
for (input, value) in typed {
|
|
input.update(cx, |input, cx| input.set_value(value, window, cx));
|
|
}
|
|
|
|
app.add_managed_forward(1, window, cx);
|
|
|
|
assert_eq!(
|
|
app.loopback_panel.managed,
|
|
vec![listening(1)],
|
|
"the rule being edited must survive an edit that could not be made"
|
|
);
|
|
assert!(
|
|
app.loopback_panel.mf_editing.is_some(),
|
|
"the form is still editing it"
|
|
);
|
|
assert!(app.loopback_panel.mf_error.is_some());
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod new_window_action_tests {
|
|
use crate::core::actions::NewWindow;
|
|
use crate::core::config::Config;
|
|
use crate::core::session::Session;
|
|
use crate::ui::app::Tty7App;
|
|
use crate::ui::windows::WindowRegistry;
|
|
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
|
|
|
|
/// `NewWindow` has to open a window, not merely exist.
|
|
///
|
|
/// Everything else about the action is a table entry — the `actions!`
|
|
/// row, the keymap slot, the palette command — and every one of those can
|
|
/// be there while the action reaches nothing. This drives the real
|
|
/// dispatch path and then asks the registry, so the assertion is "a second
|
|
/// window is open, on a workspace of its own, and the first one is still
|
|
/// here": the same `windows::open` the switcher calls for "Open in New
|
|
/// Window", with no workspace named.
|
|
#[gpui::test]
|
|
fn dispatching_new_window_opens_a_second_window_beside_the_first(cx: &mut TestAppContext) {
|
|
crate::core::config::pin_test_config_dir();
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
WindowRegistry::init(cx);
|
|
});
|
|
let window = cx.add_window(|window, cx| {
|
|
let app =
|
|
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
|
|
gpui_component::Root::new(app, window, cx)
|
|
});
|
|
let app = window
|
|
.update(cx, |root, _, _| {
|
|
root.view()
|
|
.clone()
|
|
.downcast::<Tty7App>()
|
|
.ok()
|
|
.expect("window root wraps a Tty7App")
|
|
})
|
|
.unwrap();
|
|
// Registered the way an opened window registers itself; without it the
|
|
// registry cannot tell the two windows apart afterwards.
|
|
let handle = window.into();
|
|
let weak = app.downgrade();
|
|
app.update(cx, |app, cx| {
|
|
WindowRegistry::register(cx, app.workspace, handle, weak);
|
|
});
|
|
|
|
let mut vcx = VisualTestContext::from_window(handle, cx);
|
|
vcx.background_executor.run_until_parked();
|
|
let first = app.update(&mut vcx, |app, _| app.workspace);
|
|
assert_eq!(
|
|
vcx.update(|_, cx| WindowRegistry::count(cx)),
|
|
1,
|
|
"the harness starts with exactly the one window"
|
|
);
|
|
|
|
vcx.dispatch_action(NewWindow);
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
let open = vcx.update(|_, cx| WindowRegistry::open_windows(cx));
|
|
assert_eq!(
|
|
open.len(),
|
|
2,
|
|
"NewWindow has to reach windows::open; it opened {} window(s)",
|
|
open.len()
|
|
);
|
|
assert!(
|
|
open.iter().any(|(id, _)| *id == first),
|
|
"the window the action was fired from must survive it"
|
|
);
|
|
// The registry is keyed by workspace, so a second window on the
|
|
// current one is not a thing it could tell apart from the first.
|
|
assert!(
|
|
open.iter().any(|(id, _)| *id != first),
|
|
"the new window belongs on a workspace of its own"
|
|
);
|
|
}
|
|
|
|
/// The windowless state is the one `NewWindow` exists for.
|
|
///
|
|
/// `show_tray_icon` is on by default, so closing the last window retires
|
|
/// tty7 to the tray rather than quitting it: the process is alive, the
|
|
/// menu bar is still tty7's, and there is nothing on screen. A listener
|
|
/// that lives only on `Tty7App`'s render root reaches nothing there, and
|
|
/// the chord that means "give me a window" is the one chord that has to
|
|
/// answer. `App::dispatch_action` falls through to the global listeners
|
|
/// when no window is active, which is where `keymap::init` puts this one.
|
|
#[gpui::test]
|
|
fn new_window_answers_with_no_window_to_dispatch_it(cx: &mut TestAppContext) {
|
|
crate::core::config::pin_test_config_dir();
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
WindowRegistry::init(cx);
|
|
assert_eq!(
|
|
WindowRegistry::count(cx),
|
|
0,
|
|
"the retired-to-tray state this covers has no window in it"
|
|
);
|
|
cx.dispatch_action(&NewWindow);
|
|
});
|
|
cx.run_until_parked();
|
|
cx.update(|cx| {
|
|
assert_eq!(
|
|
WindowRegistry::count(cx),
|
|
1,
|
|
"NewWindow has to reach windows::open with no window to bubble through"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod close_window_action_tests {
|
|
use crate::core::actions::CloseWindow;
|
|
use crate::core::config::Config;
|
|
use crate::core::session::Session;
|
|
use crate::ui::app::Tty7App;
|
|
use crate::ui::windows::WindowRegistry;
|
|
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
|
|
|
|
/// `CloseWindow` has to close the window, and close it the way the red
|
|
/// button does.
|
|
///
|
|
/// The action is otherwise all table entries — the `actions!` row, the
|
|
/// keymap slot, the palette command, the Keybindings label — and every one
|
|
/// of those can be in place while the action reaches nothing at all. So
|
|
/// this drives the real dispatch path and then asks two separate
|
|
/// questions: the window is gone from gpui, *and* it left the
|
|
/// `WindowRegistry` on the way out. The second is what makes it the same
|
|
/// close as the native one — `detach_workspace` is where the session is
|
|
/// saved and the workspace is retired, and a `remove_window` that skipped
|
|
/// it would still pass the first assertion while quietly dropping a
|
|
/// window's tabs on the floor.
|
|
#[gpui::test]
|
|
fn dispatching_close_window_takes_the_window_down_with_its_registration(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
crate::core::config::pin_test_config_dir();
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
gpui_component::init(cx);
|
|
cx.set_global(Config::default());
|
|
crate::ui::keymap::init(cx);
|
|
WindowRegistry::init(cx);
|
|
});
|
|
let window = cx.add_window(|window, cx| {
|
|
let app =
|
|
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
|
|
gpui_component::Root::new(app, window, cx)
|
|
});
|
|
let app = window
|
|
.update(cx, |root, _, _| {
|
|
root.view()
|
|
.clone()
|
|
.downcast::<Tty7App>()
|
|
.ok()
|
|
.expect("window root wraps a Tty7App")
|
|
})
|
|
.unwrap();
|
|
// Registered the way an opened window registers itself; the registry
|
|
// is where the close has to show up, so an unregistered window would
|
|
// make the assertion below pass for the wrong reason.
|
|
let handle = window.into();
|
|
let weak = app.downgrade();
|
|
app.update(cx, |app, cx| {
|
|
WindowRegistry::register(cx, app.workspace, handle, weak);
|
|
});
|
|
|
|
let mut vcx = VisualTestContext::from_window(handle, cx);
|
|
vcx.background_executor.run_until_parked();
|
|
assert_eq!(
|
|
vcx.update(|_, cx| WindowRegistry::count(cx)),
|
|
1,
|
|
"the harness starts with exactly the one window"
|
|
);
|
|
|
|
vcx.dispatch_action(CloseWindow);
|
|
drop(vcx);
|
|
|
|
assert!(
|
|
cx.update(|cx| cx.windows().is_empty()),
|
|
"CloseWindow has to reach `remove_window`; the window is still open"
|
|
);
|
|
assert!(
|
|
cx.update(|cx| WindowRegistry::open_windows(cx).is_empty()),
|
|
"the close has to run the same cleanup the red button runs, \
|
|
which is what takes the window out of the registry"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// #843: which pane a tab comes back to.
|
|
#[cfg(test)]
|
|
mod tab_focus_memory_tests {
|
|
use super::{Pane, PaneSlot, Tab, remember_leaf_in, replace_leaf_in};
|
|
use crate::ui::pending_pane::{PendingPane, PendingSpawn};
|
|
use gpui::{
|
|
AppContext as _, Axis, Context, Entity, IntoElement, Render, Styled as _, TestAppContext,
|
|
Window, div,
|
|
};
|
|
|
|
/// A window has to exist for focus to live in, but nothing this file asks
|
|
/// is about what a pane paints.
|
|
struct Blank;
|
|
impl Render for Blank {
|
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
div().size_full()
|
|
}
|
|
}
|
|
|
|
/// A leaf that owns a real focus handle without owning a shell. Focus
|
|
/// tracking asks the slot, not the terminal behind it, so a connecting
|
|
/// pane answers every question here exactly as a running one would — and
|
|
/// connecting panes are watched for focus now too, so this is not a
|
|
/// stand-in for the case under test but one of its cases.
|
|
fn leaf(cx: &mut Context<Blank>) -> Entity<PendingPane> {
|
|
cx.new(|cx| {
|
|
PendingPane::new(
|
|
"test",
|
|
PendingSpawn {
|
|
workspace: None,
|
|
working_directory: None,
|
|
restore_pane: None,
|
|
shell: None,
|
|
agent: None,
|
|
agent_session_id: None,
|
|
agent_launch_argv: None,
|
|
owner: None,
|
|
font_size: 14.,
|
|
},
|
|
cx,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn two_pane_tab(a: &Entity<PendingPane>, b: &Entity<PendingPane>) -> Tab {
|
|
Tab::new(Pane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::Leaf(PaneSlot::Connecting(a.clone())),
|
|
Pane::Leaf(PaneSlot::Connecting(b.clone())),
|
|
))
|
|
}
|
|
|
|
fn one_pane_tab(a: &Entity<PendingPane>) -> Tab {
|
|
Tab::new(Pane::Leaf(PaneSlot::Connecting(a.clone())))
|
|
}
|
|
|
|
/// The mechanism behind the report: `remember_active_pane`'s sample asks
|
|
/// which leaf holds focus *at that instant*, and a switch begun with focus
|
|
/// one handle away — the switcher's own search input, a palette closing,
|
|
/// the tab strip — finds no leaf and has nothing to write. This is why the
|
|
/// sample cannot be the only writer, and it is pinned here so that a change
|
|
/// making `focused_leaf` tolerant would have to argue with a test rather
|
|
/// than silently make this fix look unnecessary.
|
|
#[gpui::test]
|
|
fn a_switch_begun_off_the_panes_samples_nothing(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Blank);
|
|
let (a, b, elsewhere) = window
|
|
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), cx.focus_handle()))
|
|
.unwrap();
|
|
let tab = two_pane_tab(&a, &b);
|
|
|
|
window
|
|
.update(cx, |_, window, cx| {
|
|
let on_b = b.read(cx).focus_handle.clone();
|
|
window.focus(&on_b, cx);
|
|
assert_eq!(
|
|
tab.pane.focused_leaf(window, cx).map(|l| l.entity_id()),
|
|
Some(b.entity_id()),
|
|
"with focus in the pane the sample would have found it"
|
|
);
|
|
|
|
window.focus(&elsewhere, cx);
|
|
assert!(
|
|
tab.pane.focused_leaf(window, cx).is_none(),
|
|
"one handle off the pane and the switch-away sample has \
|
|
nothing to write"
|
|
);
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
/// So focus-in writes instead, and the tab comes back to the pane focus
|
|
/// was last in even though it had wandered off the panes before the switch
|
|
/// ever started.
|
|
#[gpui::test]
|
|
fn a_tab_comes_back_to_the_pane_focus_was_last_in(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Blank);
|
|
let (a, b) = window.update(cx, |_, _, cx| (leaf(cx), leaf(cx))).unwrap();
|
|
let mut tabs = vec![two_pane_tab(&a, &b)];
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(a.entity_id()),
|
|
"a tab nobody has worked in yet still opens on its first leaf"
|
|
);
|
|
|
|
// The reader clicks into the right-hand pane: focus arrives, and that
|
|
// is the moment the tab is told.
|
|
remember_leaf_in(&mut tabs, b.entity_id());
|
|
|
|
// Focus then leaves the panes — the switcher opens, a palette closes —
|
|
// and the tab is switched away from. `remember_active_pane` finds no
|
|
// focused leaf and writes nothing, which is now harmless.
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(b.entity_id()),
|
|
"#843: the tab has to come back to the pane the reader was in"
|
|
);
|
|
}
|
|
|
|
/// A pane dragged into another tab is focused where it lands, so the
|
|
/// arrival has to change that tab's memory and not the one it left — the
|
|
/// reason the tab is found by the leaf rather than taken to be the active
|
|
/// one.
|
|
#[gpui::test]
|
|
fn a_moved_pane_is_remembered_by_the_tab_that_holds_it_now(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Blank);
|
|
let (a, b, c) = window
|
|
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx)))
|
|
.unwrap();
|
|
// Tab 0 is the active one and holds `a`; `b` and `c` live in tab 1.
|
|
let mut tabs = vec![one_pane_tab(&a), two_pane_tab(&b, &c)];
|
|
|
|
remember_leaf_in(&mut tabs, c.entity_id());
|
|
|
|
assert_eq!(
|
|
tabs[1].focus_target().map(|l| l.entity_id()),
|
|
Some(c.entity_id()),
|
|
"the tab holding the focused pane is the one that remembers it"
|
|
);
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(a.entity_id()),
|
|
"and no other tab's memory is touched"
|
|
);
|
|
}
|
|
|
|
/// A pane that arrives after its slot has gone — a spawn landing on a
|
|
/// closed tab, a leaf killed mid-flight — belongs to no tab, and must not
|
|
/// leave a memory behind for the first tab that happens to be looked at.
|
|
#[gpui::test]
|
|
fn a_leaf_no_tab_holds_is_recorded_nowhere(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Blank);
|
|
let (a, b, gone) = window
|
|
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx)))
|
|
.unwrap();
|
|
let mut tabs = vec![two_pane_tab(&a, &b)];
|
|
|
|
remember_leaf_in(&mut tabs, b.entity_id());
|
|
remember_leaf_in(&mut tabs, gone.entity_id());
|
|
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(b.entity_id()),
|
|
"a stranger's arrival leaves the tab's own answer alone"
|
|
);
|
|
}
|
|
|
|
/// A pane focused while it was still coming up is remembered under its
|
|
/// *pending* slot, and that id dies the moment the pane lands in its
|
|
/// place. The memory has to come along with the swap, or the landing is
|
|
/// itself what puts the tab back on its first leaf.
|
|
///
|
|
/// What lands here is another slot rather than a running pane: the swap has
|
|
/// to move an id from one slot to another, and which kind of slot arrived
|
|
/// is no part of the question.
|
|
#[gpui::test]
|
|
fn a_landing_pane_inherits_what_its_pending_slot_was_told(cx: &mut TestAppContext) {
|
|
let window = cx.add_window(|_, _| Blank);
|
|
let (a, connecting, landed, other) = window
|
|
.update(cx, |_, _, cx| (leaf(cx), leaf(cx), leaf(cx), leaf(cx)))
|
|
.unwrap();
|
|
let mut tabs = vec![two_pane_tab(&a, &connecting)];
|
|
|
|
// Focus arrives while the pane is still connecting, then the pane it
|
|
// was waiting for lands in that slot.
|
|
remember_leaf_in(&mut tabs, connecting.entity_id());
|
|
replace_leaf_in(
|
|
&mut tabs,
|
|
connecting.entity_id(),
|
|
PaneSlot::Connecting(landed.clone()),
|
|
);
|
|
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(landed.entity_id()),
|
|
"#843: the memory follows the pane, not the slot it arrived in"
|
|
);
|
|
|
|
// A landing somewhere else in the tab is not an answer to this
|
|
// question and does not touch it.
|
|
replace_leaf_in(&mut tabs, a.entity_id(), PaneSlot::Connecting(other));
|
|
assert_eq!(
|
|
tabs[0].focus_target().map(|l| l.entity_id()),
|
|
Some(landed.entity_id()),
|
|
"another pane landing leaves the tab's answer alone"
|
|
);
|
|
}
|
|
|
|
/// The wiring, end to end. `watch_pane_focus` is the subscription that
|
|
/// writes the record, and a real round trip through `activate` has to come
|
|
/// back to the pane focus last arrived in — with focus off the panes well
|
|
/// before the switch was made, which is the moment the switch-away sample
|
|
/// cannot see (#843).
|
|
#[gpui::test]
|
|
fn a_tab_switch_returns_to_the_pane_focus_arrived_in(cx: &mut TestAppContext) {
|
|
use super::{test_window::harness, watch_pane_focus};
|
|
use crate::terminal::view::quiet_test_pane;
|
|
|
|
let (app, mut vcx) = harness(cx);
|
|
let (left, right, elsewhere, _held) = app.update_in(&mut vcx, |app, window, cx| {
|
|
let (left, left_stream) = quiet_test_pane(1, window, cx);
|
|
let (right, right_stream) = quiet_test_pane(2, window, cx);
|
|
let (only, only_stream) = quiet_test_pane(3, window, cx);
|
|
app.tabs.push(Tab::new(Pane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::leaf(PaneSlot::Ready(left.clone())),
|
|
Pane::leaf(PaneSlot::Ready(right.clone())),
|
|
)));
|
|
app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(only))));
|
|
app.active = 0;
|
|
// The subscription every spawn path registers for the pane it
|
|
// built.
|
|
for view in [&left, &right] {
|
|
let handle = view.read(cx).focus_handle.clone();
|
|
watch_pane_focus(&handle, view.entity_id(), window, cx);
|
|
}
|
|
cx.notify();
|
|
(
|
|
left,
|
|
right,
|
|
cx.focus_handle(),
|
|
(left_stream, right_stream, only_stream),
|
|
)
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
// The reader clicks into the right-hand pane.
|
|
app.update_in(&mut vcx, |_, window, cx| {
|
|
let handle = right.read(cx).focus_handle.clone();
|
|
handle.focus(window, cx);
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
// Focus then leaves the panes altogether — a palette closing, the tab
|
|
// strip, the switcher's own search input — before the tab is left.
|
|
app.update_in(&mut vcx, |_, window, cx| elsewhere.focus(window, cx));
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| app.activate(1, window, cx));
|
|
vcx.background_executor.run_until_parked();
|
|
app.update_in(&mut vcx, |app, window, cx| app.activate(0, window, cx));
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |_, window, cx| {
|
|
assert!(
|
|
right.read(cx).focus_handle.is_focused(window),
|
|
"#843: the tab has to come back to the pane the reader was in"
|
|
);
|
|
assert!(
|
|
!left.read(cx).focus_handle.is_focused(window),
|
|
"and not to the first leaf"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A split tab on screen with both panes watched the way every spawn path
|
|
/// watches them, plus a handle off the panes for focus to wander to.
|
|
#[allow(clippy::type_complexity)]
|
|
fn watched_split(
|
|
cx: &mut TestAppContext,
|
|
) -> (
|
|
Entity<super::Tty7App>,
|
|
gpui::VisualTestContext,
|
|
Entity<crate::terminal::view::TerminalView>,
|
|
Entity<crate::terminal::view::TerminalView>,
|
|
gpui::FocusHandle,
|
|
impl Sized,
|
|
) {
|
|
use super::{test_window::harness, watch_pane_focus};
|
|
use crate::terminal::view::quiet_test_pane;
|
|
|
|
let (app, mut vcx) = harness(cx);
|
|
let (left, right, elsewhere, held) = app.update_in(&mut vcx, |app, window, cx| {
|
|
let (left, left_stream) = quiet_test_pane(1, window, cx);
|
|
let (right, right_stream) = quiet_test_pane(2, window, cx);
|
|
app.tabs.push(Tab::new(Pane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::leaf(PaneSlot::Ready(left.clone())),
|
|
Pane::leaf(PaneSlot::Ready(right.clone())),
|
|
)));
|
|
app.active = 0;
|
|
for view in [&left, &right] {
|
|
let handle = view.read(cx).focus_handle.clone();
|
|
watch_pane_focus(&handle, view.entity_id(), window, cx);
|
|
}
|
|
cx.notify();
|
|
(left, right, cx.focus_handle(), (left_stream, right_stream))
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
(app, vcx, left, right, elsewhere, held)
|
|
}
|
|
|
|
/// Zoom in on a pane, zoom back out, and the cursor is still in that pane
|
|
/// (#869). The pane being un-zoomed is the answer outright — it is the one
|
|
/// that was on screen to work in — so it is not a question for the tab's
|
|
/// memory, which only focus-in writes and which nothing guarantees is
|
|
/// current at the instant the toggle lands.
|
|
#[gpui::test]
|
|
fn unzooming_leaves_focus_in_the_pane_that_was_zoomed(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, left, right, _elsewhere, _held) = watched_split(cx);
|
|
|
|
// Into the right-hand pane by keyboard, the road the report took.
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
left.read(cx).focus_handle.clone().focus(window, cx);
|
|
app.cycle_pane(true, window, cx);
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.toggle_maximize(window, cx);
|
|
assert_eq!(
|
|
app.maximized.as_ref().map(|l| l.entity_id()),
|
|
Some(right.entity_id()),
|
|
"the pane focus is in is the one zoomed"
|
|
);
|
|
// Whatever the tab's memory says while zoomed — here the stale
|
|
// answer the report found — must not decide where un-zooming goes.
|
|
app.tabs[0].last_focused = Some(left.entity_id());
|
|
app.toggle_maximize(window, cx);
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
assert!(app.maximized.is_none());
|
|
assert!(
|
|
right.read(cx).focus_handle.is_focused(window),
|
|
"#869: un-zooming keeps focus in the pane that was zoomed"
|
|
);
|
|
assert_eq!(
|
|
app.tabs[0].last_focused,
|
|
Some(right.entity_id()),
|
|
"and the tab remembers it, so a switch away and back agrees"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Zooming with focus off the panes — a palette just closed, the tab strip
|
|
/// clicked — zooms the pane the tab remembers, not whichever leaf happens
|
|
/// to be first: the same pane switching back to the tab would focus.
|
|
#[gpui::test]
|
|
fn zooming_from_off_the_panes_zooms_the_remembered_pane(cx: &mut TestAppContext) {
|
|
let (app, mut vcx, _left, right, elsewhere, _held) = watched_split(cx);
|
|
|
|
app.update_in(&mut vcx, |_, window, cx| {
|
|
right.read(cx).focus_handle.clone().focus(window, cx);
|
|
});
|
|
vcx.background_executor.run_until_parked();
|
|
app.update_in(&mut vcx, |_, window, cx| elsewhere.focus(window, cx));
|
|
vcx.background_executor.run_until_parked();
|
|
|
|
app.update_in(&mut vcx, |app, window, cx| {
|
|
app.toggle_maximize(window, cx);
|
|
assert_eq!(
|
|
app.maximized.as_ref().map(|l| l.entity_id()),
|
|
Some(right.entity_id()),
|
|
"the zoom lands on the pane the reader was last in"
|
|
);
|
|
assert!(right.read(cx).focus_handle.is_focused(window));
|
|
});
|
|
}
|
|
}
|