967 lines
38 KiB
Rust
967 lines
38 KiB
Rust
use std::any::Any;
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
use std::sync::mpsc::{SendError, SyncSender};
|
|
use std::sync::Arc;
|
|
use std::thread::JoinHandle;
|
|
|
|
use anyhow::Context as _;
|
|
use async_broadcast::InactiveReceiver;
|
|
use galaxy_core::SessionId;
|
|
use galaxyui::r#async::executor::Background;
|
|
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, ViewHandle};
|
|
#[cfg(unix)]
|
|
use nix::sys::termios::LocalFlags;
|
|
use parking_lot::{FairMutex, Mutex};
|
|
use pathfinder_geometry::vector::Vector2F;
|
|
use settings::Setting as _;
|
|
|
|
use super::event_loop::EventLoop;
|
|
use super::shell::{ShellStarter, ShellStarterSource};
|
|
#[cfg(unix)]
|
|
use super::terminal_attributes::TerminalAttributesPoller;
|
|
use super::{mio_channel, recorder};
|
|
use crate::ai::aws_credentials::AwsCredentialRefresher as _;
|
|
use crate::ai::blocklist::SerializedBlockListItem;
|
|
use crate::auth::auth_state::AuthState;
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::banner::BannerState;
|
|
use crate::context_chips::prompt::Prompt;
|
|
use crate::context_chips::ContextChipKind;
|
|
use crate::features::FeatureFlag;
|
|
use crate::persistence::ModelEvent;
|
|
use crate::send_telemetry_on_executor;
|
|
use crate::server::telemetry::TelemetryEvent;
|
|
use crate::settings::{DebugSettings, PrivacySettings, SshSettings};
|
|
use crate::terminal::available_shells::{AvailableShell, AvailableShells};
|
|
use crate::terminal::color::List as ColorList;
|
|
use crate::terminal::event_listener::ChannelEventListener;
|
|
#[cfg(unix)]
|
|
use crate::terminal::local_tty::terminal_attributes::Event as TerminalAttributesPollerEvent;
|
|
use crate::terminal::local_tty::{Pty, PtyOptions};
|
|
use crate::terminal::model::session::Sessions;
|
|
#[cfg(unix)]
|
|
use crate::terminal::model::terminal_model::BlockIndex;
|
|
use crate::terminal::model::terminal_model::ExitReason;
|
|
#[cfg(unix)]
|
|
use crate::terminal::model_events::ModelEvent as TerminalModelEvent;
|
|
use crate::terminal::model_events::ModelEventDispatcher;
|
|
use crate::terminal::session_settings::{SessionSettings, ToolbarChipSelection};
|
|
use crate::terminal::shared_session::sharer::network::Network;
|
|
use crate::terminal::shared_session::{IsSharedSessionCreator, SharedSessionStatus};
|
|
use crate::terminal::shell::ShellName;
|
|
use crate::terminal::warpify::settings::WarpifySettings;
|
|
use crate::terminal::writeable_pty::pty_controller::{EventLoopSendError, EventLoopSender};
|
|
use crate::terminal::writeable_pty::terminal_manager_util::{
|
|
init_pty_controller_model, init_remote_server_controller, wire_up_pty_controller_with_surface,
|
|
};
|
|
use crate::terminal::writeable_pty::{self, Message, PtyIntentEvent, TerminalSurface};
|
|
use crate::terminal::{
|
|
terminal_manager, ShellLaunchData, ShellLaunchState, SizeInfo,
|
|
TerminalManager as TerminalManagerTrait, TerminalModel, PTY_READS_BROADCAST_CHANNEL_SIZE,
|
|
};
|
|
|
|
type PtyController = writeable_pty::PtyController<mio_channel::Sender<Message>>;
|
|
type RemoteServerController =
|
|
writeable_pty::remote_server_controller::RemoteServerController<mio_channel::Sender<Message>>;
|
|
|
|
/// Owns a local terminal session: the terminal model, PTY event loop, PTY
|
|
/// controller, and a terminal surface.
|
|
///
|
|
/// Holds onto data that needs to live as long as the session does (e.g. the
|
|
/// event loop join handle).
|
|
pub struct TerminalManager<S> {
|
|
event_loop_tx: Arc<Mutex<mio_channel::Sender<Message>>>,
|
|
/// This is an `Option` so that we can take ownership of the inner
|
|
/// `JoinHandle` in `TerminalManager::drop`.
|
|
event_loop_handle: Option<JoinHandle<()>>,
|
|
pub(super) model: Arc<FairMutex<TerminalModel>>,
|
|
pub(super) view: ViewHandle<S>,
|
|
|
|
/// The manager is responsible for managing the lifetime
|
|
/// of the terminal attributes poller. None if the event loop has not yet started.
|
|
#[cfg(unix)]
|
|
#[allow(dead_code)]
|
|
terminal_attributes_poller: Option<ModelHandle<TerminalAttributesPoller>>,
|
|
|
|
/// The manager is responsible for managing the lifetime
|
|
/// of the PTY controller.
|
|
pty_controller: ModelHandle<PtyController>,
|
|
|
|
/// The manager is responsible for managing the lifetime of the remote server controller.
|
|
remote_server_controller: ModelHandle<RemoteServerController>,
|
|
|
|
/// The process ID of the PTY. Purely used for integration tests. None if the PTY has not yet
|
|
/// been started.
|
|
#[cfg(feature = "integration_tests")]
|
|
pub(super) pid: Option<u32>,
|
|
|
|
/// An inactive receiver for PTY reads that we can upgrade to an active
|
|
/// receiver as needed. We prefer to not create active receivers eagerly
|
|
/// to avoid unnecessary allocations of data coming from the PTY (high throughput).
|
|
/// Note that we need to hold onto the inactive receiver so that the channel isn't closed prematurely.
|
|
inactive_pty_reads_rx: InactiveReceiver<Arc<Vec<u8>>>,
|
|
|
|
/// The sharer side of the session sharing protocol. [`Some`] only when a
|
|
/// shared session connection is ongoing.
|
|
pub(super) session_sharer: Rc<RefCell<Option<ModelHandle<Network>>>>,
|
|
}
|
|
|
|
/// Shared inputs needed to construct a terminal surface for a local PTY.
|
|
pub struct TerminalSurfaceInit {
|
|
pub wakeups_rx: async_channel::Receiver<()>,
|
|
pub model_events: ModelHandle<ModelEventDispatcher>,
|
|
pub model: Arc<FairMutex<TerminalModel>>,
|
|
pub sessions: ModelHandle<Sessions>,
|
|
pub size_info: SizeInfo,
|
|
pub colors: ColorList,
|
|
pub inactive_pty_reads_rx: InactiveReceiver<Arc<Vec<u8>>>,
|
|
}
|
|
/// A newly constructed terminal surface and its manager post-wiring callback.
|
|
pub struct TerminalSurfaceResult<S, PostWire> {
|
|
pub surface: ViewHandle<S>,
|
|
pub post_wire: PostWire,
|
|
}
|
|
|
|
/// One-shot resources consumed when the shell is determined and the PTY starts.
|
|
struct ShellStartupResources {
|
|
event_loop_rx: mio_channel::Receiver<Message>,
|
|
channel_event_proxy: ChannelEventListener,
|
|
#[cfg(unix)]
|
|
model_events: ModelHandle<ModelEventDispatcher>,
|
|
}
|
|
|
|
/// Handles created for a local terminal manager and its surface.
|
|
pub struct TerminalManagerInit<S> {
|
|
pub manager: ModelHandle<Box<dyn TerminalManagerTrait>>,
|
|
pub surface: ViewHandle<S>,
|
|
}
|
|
/// Adapts a TUI-owned surface manager to Warp's type-erased manager contract.
|
|
struct TuiTerminalManager<S>(TerminalManager<S>);
|
|
|
|
impl<S: 'static> TerminalManagerTrait for TuiTerminalManager<S> {
|
|
fn model(&self) -> Arc<FairMutex<TerminalModel>> {
|
|
self.0.model()
|
|
}
|
|
|
|
fn as_any(&self) -> &dyn Any {
|
|
&self.0
|
|
}
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
impl<S> Drop for TerminalManager<S> {
|
|
fn drop(&mut self) {
|
|
self.shutdown_event_loop();
|
|
}
|
|
}
|
|
|
|
impl<S> TerminalManager<S> {
|
|
/// Creates a local terminal manager model and terminal surface.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn create_model<PostWire>(
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
is_shared_session_creator: IsSharedSessionCreator,
|
|
all_restored_blocks: Option<&Vec<SerializedBlockListItem>>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
initial_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut AppContext,
|
|
create_surface: impl FnOnce(
|
|
TerminalSurfaceInit,
|
|
&mut AppContext,
|
|
) -> TerminalSurfaceResult<S, PostWire>,
|
|
) -> TerminalManagerInit<S>
|
|
where
|
|
S: TerminalSurface,
|
|
<S as Entity>::Event: PtyIntentEvent,
|
|
Self: TerminalManagerTrait,
|
|
PostWire: FnOnce(&mut Self, &ViewHandle<S>, &mut AppContext),
|
|
{
|
|
Self::create_model_with_manager(
|
|
startup_directory,
|
|
env_vars,
|
|
is_shared_session_creator,
|
|
all_restored_blocks,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
initial_size,
|
|
model_event_sender,
|
|
chosen_shell,
|
|
ctx,
|
|
create_surface,
|
|
|manager| Box::new(manager),
|
|
)
|
|
}
|
|
|
|
/// Creates a local terminal manager for a TUI-owned terminal surface.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn create_tui_model<PostWire>(
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
is_shared_session_creator: IsSharedSessionCreator,
|
|
all_restored_blocks: Option<&Vec<SerializedBlockListItem>>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
initial_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut AppContext,
|
|
create_surface: impl FnOnce(
|
|
TerminalSurfaceInit,
|
|
&mut AppContext,
|
|
) -> TerminalSurfaceResult<S, PostWire>,
|
|
) -> TerminalManagerInit<S>
|
|
where
|
|
S: TerminalSurface,
|
|
<S as Entity>::Event: PtyIntentEvent,
|
|
PostWire: FnOnce(&mut Self, &ViewHandle<S>, &mut AppContext),
|
|
{
|
|
Self::create_model_with_manager(
|
|
startup_directory,
|
|
env_vars,
|
|
is_shared_session_creator,
|
|
all_restored_blocks,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
initial_size,
|
|
model_event_sender,
|
|
chosen_shell,
|
|
ctx,
|
|
create_surface,
|
|
|manager| Box::new(TuiTerminalManager(manager)),
|
|
)
|
|
}
|
|
|
|
/// Creates a manager using the supplied type-erasure adapter.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn create_model_with_manager<PostWire, BoxManager>(
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
is_shared_session_creator: IsSharedSessionCreator,
|
|
all_restored_blocks: Option<&Vec<SerializedBlockListItem>>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
initial_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut AppContext,
|
|
create_surface: impl FnOnce(
|
|
TerminalSurfaceInit,
|
|
&mut AppContext,
|
|
) -> TerminalSurfaceResult<S, PostWire>,
|
|
box_manager: BoxManager,
|
|
) -> TerminalManagerInit<S>
|
|
where
|
|
S: TerminalSurface,
|
|
<S as Entity>::Event: PtyIntentEvent,
|
|
PostWire: FnOnce(&mut Self, &ViewHandle<S>, &mut AppContext),
|
|
BoxManager: FnOnce(Self) -> Box<dyn TerminalManagerTrait> + 'static,
|
|
{
|
|
let (wakeups_tx, wakeups_rx) = async_channel::unbounded();
|
|
let (events_tx, events_rx) = async_channel::unbounded();
|
|
let (executor_command_tx, executor_command_rx) = async_channel::unbounded();
|
|
let (event_loop_tx, event_loop_rx) = mio_channel::channel();
|
|
|
|
// Create the broadcast channel to receive data from the PTY, but deactivate it immediately.
|
|
// We only want to create active receivers as necessary.
|
|
let (pty_reads_tx, pty_reads_rx) =
|
|
async_broadcast::broadcast(PTY_READS_BROADCAST_CHANNEL_SIZE);
|
|
let inactive_pty_reads_rx = pty_reads_rx.deactivate();
|
|
|
|
let channel_event_proxy = ChannelEventListener::new(wakeups_tx, events_tx, pty_reads_tx);
|
|
|
|
// Initialize the sessions model.
|
|
let sessions = ctx.add_model(|ctx| Sessions::new(executor_command_tx.clone(), ctx));
|
|
|
|
let model_events =
|
|
ctx.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx));
|
|
|
|
// Have ApiKeyManager subscribe to block completion events for AWS credential refresh
|
|
ai::api_keys::ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.register_model_event_dispatcher(&model_events, ctx);
|
|
});
|
|
|
|
let preferred_shell = chosen_shell.unwrap_or_else(|| {
|
|
AvailableShells::handle(ctx)
|
|
.read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx))
|
|
});
|
|
let wsl_name_or_shell_starter = ShellStarter::init(preferred_shell.clone());
|
|
|
|
// Create the terminal model with all restored blocks
|
|
log::info!(
|
|
"Creating terminal model with {} restored blocks",
|
|
all_restored_blocks
|
|
.as_ref()
|
|
.map(|blocks| blocks.len())
|
|
.unwrap_or(0)
|
|
);
|
|
let model = terminal_manager::create_terminal_model(
|
|
startup_directory.clone(),
|
|
all_restored_blocks,
|
|
initial_size,
|
|
channel_event_proxy.clone(),
|
|
ShellLaunchState::DeterminingShell {
|
|
available_shell: Some(preferred_shell),
|
|
display_name: wsl_name_or_shell_starter
|
|
.as_ref()
|
|
.map(|wsl_name_or_shell_starter| wsl_name_or_shell_starter.name())
|
|
.unwrap_or(ShellName::LessDescriptive("Shell".to_owned())),
|
|
},
|
|
ctx,
|
|
);
|
|
let colors = model.colors();
|
|
let model = Arc::new(FairMutex::new(model));
|
|
|
|
// This is purely for measuring throughput on WarpDev.
|
|
if FeatureFlag::RecordPtyThroughput.is_enabled() {
|
|
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
|
recorder::record_pty_throughput(
|
|
inactive_pty_reads_rx.clone().activate(),
|
|
model.clone(),
|
|
auth_state.clone(),
|
|
ctx.background_executor().to_owned(),
|
|
);
|
|
}
|
|
|
|
// If this session should be a shared-session creator, configure its initial
|
|
// shared-session state before the surface is constructed, so that bootstrap
|
|
// events can observe the correct pending status and source type.
|
|
match is_shared_session_creator {
|
|
IsSharedSessionCreator::Yes { source }
|
|
if FeatureFlag::CreatingSharedSessions.is_enabled() =>
|
|
{
|
|
model.lock().set_shared_session_status(
|
|
SharedSessionStatus::SharePendingPreBootstrap { source },
|
|
);
|
|
log::info!("Configured terminal to start sharing after bootstrap");
|
|
}
|
|
IsSharedSessionCreator::Yes { .. } => {
|
|
log::warn!(
|
|
"Session sharing was requested, but CreatingSharedSessions is disabled; \
|
|
skipping shared-session startup"
|
|
);
|
|
}
|
|
IsSharedSessionCreator::No => {}
|
|
}
|
|
|
|
// Initialize the PtyController.
|
|
let pty_controller = init_pty_controller_model(
|
|
event_loop_tx.clone(),
|
|
executor_command_rx,
|
|
model_events.clone(),
|
|
sessions.clone(),
|
|
model.clone(),
|
|
ctx,
|
|
);
|
|
|
|
// Initialize the RemoteServerController.
|
|
let remote_server_controller =
|
|
init_remote_server_controller(&pty_controller, &model_events, ctx);
|
|
let size_info = model.lock().block_list().size().to_owned();
|
|
let TerminalSurfaceResult { surface, post_wire } = create_surface(
|
|
TerminalSurfaceInit {
|
|
wakeups_rx,
|
|
model_events: model_events.clone(),
|
|
model: model.clone(),
|
|
sessions: sessions.clone(),
|
|
size_info,
|
|
colors,
|
|
inactive_pty_reads_rx: inactive_pty_reads_rx.clone(),
|
|
},
|
|
ctx,
|
|
);
|
|
wire_up_pty_controller_with_surface(
|
|
&pty_controller,
|
|
&surface,
|
|
model.clone(),
|
|
sessions.clone(),
|
|
model_event_sender,
|
|
ctx,
|
|
);
|
|
let mut terminal_manager = Self {
|
|
event_loop_tx: Arc::new(Mutex::new(event_loop_tx)),
|
|
model,
|
|
event_loop_handle: None,
|
|
view: surface.clone(),
|
|
#[cfg(unix)]
|
|
terminal_attributes_poller: None,
|
|
pty_controller,
|
|
remote_server_controller,
|
|
#[cfg(feature = "integration_tests")]
|
|
pid: None,
|
|
inactive_pty_reads_rx,
|
|
session_sharer: Rc::new(RefCell::new(None)),
|
|
};
|
|
|
|
// Run surface-specific wiring after the manager exists, because this
|
|
// step may need manager-owned controllers and retained handles.
|
|
post_wire(&mut terminal_manager, &surface, ctx);
|
|
|
|
let terminal_surface = surface.clone();
|
|
let shell_startup_resources = ShellStartupResources {
|
|
event_loop_rx,
|
|
channel_event_proxy,
|
|
#[cfg(unix)]
|
|
model_events,
|
|
};
|
|
|
|
let terminal_manager_model = ctx.add_model(|ctx| {
|
|
let terminal_manager = box_manager(terminal_manager);
|
|
ctx.spawn(
|
|
async move {
|
|
match wsl_name_or_shell_starter {
|
|
Some(starter_source) => starter_source.to_shell_starter_source().await,
|
|
None => None,
|
|
}
|
|
},
|
|
move |terminal_manager: &mut Box<dyn TerminalManagerTrait>,
|
|
shell_starter_source,
|
|
ctx| {
|
|
let Some(terminal_manager) =
|
|
TerminalManagerTrait::as_any_mut(terminal_manager.as_mut())
|
|
.downcast_mut::<Self>()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
on_shell_determined(
|
|
terminal_manager,
|
|
startup_directory,
|
|
env_vars,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
shell_startup_resources,
|
|
shell_starter_source,
|
|
ctx,
|
|
)
|
|
},
|
|
);
|
|
|
|
terminal_manager
|
|
});
|
|
|
|
TerminalManagerInit {
|
|
manager: terminal_manager_model,
|
|
surface: terminal_surface,
|
|
}
|
|
}
|
|
|
|
/// Returns the terminal model owned by this manager.
|
|
pub(crate) fn model(&self) -> Arc<FairMutex<TerminalModel>> {
|
|
self.model.clone()
|
|
}
|
|
|
|
/// Returns the remote server controller owned by this manager.
|
|
pub(super) fn remote_server_controller(&self) -> ModelHandle<RemoteServerController> {
|
|
self.remote_server_controller.clone()
|
|
}
|
|
|
|
/// Sends a shutdown message to the PTY event loop and waits for it to
|
|
/// process that event.
|
|
pub(super) fn shutdown_event_loop(&mut self) {
|
|
let shutdown_res = self.event_loop_tx.lock().send(Message::Shutdown);
|
|
// Happens normally if the event loop has already been terminated (so the channel is now gone).
|
|
if let Err(e) = shutdown_res {
|
|
log::info!("Failed to send Shutdown {e:?}");
|
|
}
|
|
|
|
if let Some(join_handle) = self.event_loop_handle.take() {
|
|
if let Err(e) = join_handle.join() {
|
|
log::error!("Failed to join event loop handle {e:?}");
|
|
}
|
|
} else {
|
|
log::error!("No event loop handle to join when dropping terminal manager.")
|
|
}
|
|
|
|
self.inactive_pty_reads_rx.close();
|
|
}
|
|
}
|
|
|
|
/// Callback invoked upon determining the shell to be spawned when starting the event loop.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn on_shell_determined<S: TerminalSurface>(
|
|
manager: &mut TerminalManager<S>,
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
shell_startup_resources: ShellStartupResources,
|
|
shell_starter_source: Option<ShellStarterSource>,
|
|
ctx: &mut ModelContext<Box<dyn TerminalManagerTrait>>,
|
|
) where
|
|
<S as Entity>::Event: PtyIntentEvent,
|
|
{
|
|
// This is executed as a callback and the window could be closed in the interim.
|
|
if !ctx.is_window_open(manager.view.window_id(ctx)) {
|
|
log::warn!("Window was closed before shell was determined, aborting shell startup.");
|
|
return;
|
|
}
|
|
|
|
log::debug!("Using shell starter source {shell_starter_source:?}");
|
|
let bg_executor = ctx.background_executor();
|
|
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
|
|
|
let is_fallback_shell = matches!(
|
|
shell_starter_source,
|
|
Some(ShellStarterSource::Fallback { .. })
|
|
);
|
|
let shell_starter = shell_starter_source
|
|
.map(|source| get_shell_starter_internal(source, bg_executor, auth_state));
|
|
let shell_starter = match shell_starter {
|
|
Some(shell_starter) => shell_starter,
|
|
None => {
|
|
log::error!("Could not compute fallback shell");
|
|
manager.view.update(ctx, |surface, ctx| {
|
|
surface.on_pty_spawn_failed(
|
|
anyhow::Error::msg("Could not find a fallback shell. If you have PowerShell or WSL installed, please file an issue."),
|
|
ctx,
|
|
);
|
|
});
|
|
manager.model().lock().exit(ExitReason::ShellNotFound);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// In WSL, default to the WSL home directory, not the native Windows home directory.
|
|
let startup_directory = if let (ShellStarter::Wsl(wsl_shell_starter), None) =
|
|
(&shell_starter, &startup_directory)
|
|
{
|
|
wsl_shell_starter.home_directory()
|
|
} else {
|
|
startup_directory
|
|
};
|
|
|
|
// Show a "shell unsupported" banner, if applicable.
|
|
if is_fallback_shell
|
|
&& user_default_shell_unsupported_banner_model_handle.as_ref(ctx)
|
|
== &BannerState::NotDismissed
|
|
{
|
|
user_default_shell_unsupported_banner_model_handle.update(ctx, |model, ctx| {
|
|
*model = BannerState::Open;
|
|
ctx.notify();
|
|
})
|
|
}
|
|
|
|
manager
|
|
.model()
|
|
.lock()
|
|
.set_login_shell_spawned(shell_starter.shell_type());
|
|
|
|
let shell_launch_data = match &shell_starter {
|
|
ShellStarter::Direct(shell_starter) => ShellLaunchData::Executable {
|
|
executable_path: shell_starter.logical_shell_path().to_owned(),
|
|
shell_type: shell_starter.shell_type(),
|
|
},
|
|
ShellStarter::DockerSandbox(docker_starter) => ShellLaunchData::Executable {
|
|
executable_path: docker_starter.logical_shell_path().to_owned(),
|
|
shell_type: docker_starter.shell_type(),
|
|
},
|
|
ShellStarter::Wsl(shell_starter) => ShellLaunchData::WSL {
|
|
distro: shell_starter.distribution().to_owned(),
|
|
},
|
|
ShellStarter::MSYS2(shell_starter) => ShellLaunchData::MSYS2 {
|
|
executable_path: shell_starter.logical_shell_path().to_owned(),
|
|
shell_type: shell_starter.shell_type(),
|
|
},
|
|
};
|
|
|
|
// This needs to be done before bootstrapping starts (i.e. before spawning the event loop below).
|
|
manager
|
|
.model()
|
|
.lock()
|
|
.set_pending_shell_launch_data(shell_launch_data.clone());
|
|
|
|
// Register the session ID that was generated during shell starter construction.
|
|
// For bash, fish, and PowerShell, the session ID is already baked into the command
|
|
// args. For zsh and MSYS2, enqueue_init_script injects this same ID.
|
|
let generated_session_id = match &shell_starter {
|
|
ShellStarter::Direct(starter) | ShellStarter::MSYS2(starter) => starter.session_id(),
|
|
ShellStarter::DockerSandbox(starter) => starter.session_id(),
|
|
ShellStarter::Wsl(starter) => starter.session_id(),
|
|
};
|
|
manager
|
|
.model()
|
|
.lock()
|
|
.register_session_id(generated_session_id);
|
|
|
|
// Enqueue the init shell script (for shells that need it), then create
|
|
// the PTY and start its corresponding event loop.
|
|
let ShellStartupResources {
|
|
event_loop_rx,
|
|
channel_event_proxy,
|
|
#[cfg(unix)]
|
|
model_events,
|
|
} = shell_startup_resources;
|
|
let model = manager.model();
|
|
#[cfg(windows)]
|
|
let event_loop_tx = manager.event_loop_tx.lock().clone();
|
|
let pty = match manager
|
|
.enqueue_init_script(&shell_starter, generated_session_id)
|
|
.context("Failed to write shell init script to the pty")
|
|
.and_then(|_| {
|
|
TerminalManager::<S>::create_pty(
|
|
startup_directory,
|
|
shell_starter,
|
|
env_vars,
|
|
model.clone(),
|
|
#[cfg(windows)]
|
|
event_loop_tx,
|
|
ctx,
|
|
)
|
|
}) {
|
|
Ok(pty) => pty,
|
|
Err(err) => {
|
|
log::error!("Failed to spawn pty: {err:#}");
|
|
manager.view.update(ctx, |surface, ctx| {
|
|
surface.on_pty_spawn_failed(err, ctx);
|
|
});
|
|
manager.model().lock().exit(ExitReason::PtySpawnFailed);
|
|
return;
|
|
}
|
|
};
|
|
|
|
#[cfg(feature = "integration_tests")]
|
|
let pid = pty.get_pid();
|
|
#[cfg(unix)]
|
|
let fd = pty.get_fd();
|
|
|
|
// Create the channel above and pass the receving side to the event loop.
|
|
let event_loop_handle = TerminalManager::<S>::start_pty_event_loop(
|
|
pty,
|
|
event_loop_rx,
|
|
model.clone(),
|
|
channel_event_proxy,
|
|
);
|
|
|
|
manager.event_loop_handle = Some(event_loop_handle);
|
|
#[cfg(feature = "integration_tests")]
|
|
{
|
|
manager.pid = Some(pid);
|
|
}
|
|
|
|
manager.view.update(ctx, |surface, ctx| {
|
|
surface.on_shell_determined(ctx);
|
|
surface.on_active_shell_launch_data_updated(Some(shell_launch_data), ctx);
|
|
});
|
|
|
|
// Initialize the terminal attributes poller.
|
|
// TODO(CORE-2297): Implement TerminalPoller on Windows.
|
|
#[cfg(unix)]
|
|
{
|
|
let terminal_attributes_poller = ctx.add_model(|_| TerminalAttributesPoller::new(fd));
|
|
wire_up_terminal_attribute_poller_with_surface(
|
|
&terminal_attributes_poller,
|
|
&manager.view,
|
|
&model_events,
|
|
model.clone(),
|
|
ctx,
|
|
);
|
|
|
|
manager.terminal_attributes_poller = Some(terminal_attributes_poller);
|
|
}
|
|
}
|
|
|
|
impl<S> TerminalManager<S> {
|
|
/// Sends bindkey to notify shell process to switch to PS1 logic for prompt
|
|
/// with the combined prompt/command grid (we restore the saved PS1 value).
|
|
pub fn send_switch_to_ps1_bindkey(&self, app_ctx: &mut AppContext) {
|
|
self.pty_controller.update(app_ctx, |pty_controller, ctx| {
|
|
pty_controller.send_switch_to_ps1_bindkey(ctx);
|
|
});
|
|
}
|
|
|
|
/// Sends bindkey to notify shell process to switch to Warp prompt logic for prompt
|
|
/// with the combined prompt/command grid (we unset the PS1, but save the value for potential
|
|
/// future restoration).
|
|
pub fn send_switch_to_warp_prompt_bindkey(&self, app_ctx: &mut AppContext) {
|
|
self.pty_controller.update(app_ctx, |pty_controller, ctx| {
|
|
pty_controller.send_switch_to_warp_prompt_bindkey(ctx);
|
|
});
|
|
}
|
|
|
|
fn enqueue_init_script(
|
|
&self,
|
|
shell_starter: &ShellStarter,
|
|
session_id: SessionId,
|
|
) -> Result<(), SendError<Message>> {
|
|
let shell_type = shell_starter.shell_type();
|
|
if shell_type == crate::terminal::shell::ShellType::Zsh
|
|
// For more on why this is necessary on Git Bash, see https://linear.app/warpdotdev/issue/CORE-3202.
|
|
|| shell_starter.is_msys2()
|
|
{
|
|
let init_shell_script = crate::terminal::bootstrap::init_shell_script_for_shell(
|
|
shell_type,
|
|
&crate::ASSETS,
|
|
session_id,
|
|
);
|
|
let tx = self.event_loop_tx.lock();
|
|
tx.send(Message::Input(init_shell_script.into_bytes().into()))?;
|
|
tx.send(Message::Input(shell_type.execute_command_bytes().into()))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn create_pty(
|
|
startup_directory: Option<PathBuf>,
|
|
shell_starter: ShellStarter,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
#[cfg(windows)] event_loop_tx: mio_channel::Sender<Message>,
|
|
ctx: &mut AppContext,
|
|
) -> anyhow::Result<Pty> {
|
|
let is_shell_debug_mode_enabled = *DebugSettings::as_ref(ctx)
|
|
.is_shell_debug_mode_enabled
|
|
.value();
|
|
let is_honor_ps1_enabled = *SessionSettings::as_ref(ctx).honor_ps1;
|
|
let is_crash_reporting_enabled = PrivacySettings::as_ref(ctx).is_crash_reporting_enabled;
|
|
|
|
// Determine whether the Node.js Version chip is enabled anywhere it could be
|
|
// shown (the Warp prompt, the agent footer, or the CLI agent footer). When it
|
|
// is not, the shell bootstrap skips the expensive per-prompt `node --version`
|
|
// detection. The chip value is fed by the same precmd payload regardless of
|
|
// where it is displayed, so we must check all three locations.
|
|
let node_version_chip_enabled = {
|
|
let in_prompt = !is_honor_ps1_enabled
|
|
&& Prompt::as_ref(ctx)
|
|
.chip_kinds()
|
|
.contains(&ContextChipKind::NodeVersion);
|
|
let settings = SessionSettings::as_ref(ctx);
|
|
in_prompt
|
|
|| settings
|
|
.agent_footer_chip_selection
|
|
.all_chips()
|
|
.contains(&ContextChipKind::NodeVersion)
|
|
|| settings
|
|
.cli_agent_footer_chip_selection
|
|
.all_chips()
|
|
.contains(&ContextChipKind::NodeVersion)
|
|
};
|
|
|
|
// `enable_ssh_warpification` is the single source of truth for whether the SSH
|
|
// wrapper is active. The bootstrap scripts check `WARP_USE_SSH_WRAPPER` (derived
|
|
// from this value) before invoking `warp_ssh_helper`, which spawns the ControlMaster
|
|
// and opens agent-protocol channels.
|
|
let enable_ssh_wrapper = *WarpifySettings::as_ref(ctx)
|
|
.enable_ssh_warpification
|
|
.value();
|
|
|
|
// Only meaningful when the legacy ControlMaster wrapper is active.
|
|
let reuse_ssh_control_master = enable_ssh_wrapper
|
|
&& *SshSettings::as_ref(ctx)
|
|
.reuse_existing_control_master
|
|
.value();
|
|
|
|
let size: SizeInfo = model.lock().block_list().size().to_owned();
|
|
let options = PtyOptions {
|
|
size,
|
|
window_id: None,
|
|
shell_starter,
|
|
start_dir: startup_directory,
|
|
env_vars,
|
|
enable_ssh_wrapper,
|
|
reuse_ssh_control_master,
|
|
shell_debug_mode: is_shell_debug_mode_enabled,
|
|
honor_ps1: is_honor_ps1_enabled,
|
|
node_version_chip_enabled,
|
|
close_fds: true,
|
|
};
|
|
|
|
Pty::new(
|
|
options,
|
|
is_crash_reporting_enabled,
|
|
#[cfg(windows)]
|
|
event_loop_tx,
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Start's the PTY event loop, returning a sender for the event loop and the event loop's join handle.
|
|
fn start_pty_event_loop(
|
|
pty: Pty,
|
|
rx: mio_channel::Receiver<Message>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
channel_event_proxy: ChannelEventListener,
|
|
) -> JoinHandle<()> {
|
|
// Create the event loop and get a handle to the injector.
|
|
let event_loop = EventLoop::new(model, channel_event_proxy, pty, rx);
|
|
|
|
// Spawn the event loop on a separate thread to interact with the PTY and write the data back
|
|
// to the terminal.
|
|
event_loop.spawn()
|
|
}
|
|
}
|
|
|
|
/// Wires the Unix terminal-attributes poller to a terminal surface.
|
|
///
|
|
/// The poller is driven off `ModelEventDispatcher` block start/complete events
|
|
/// rather than surface-specific view events, and termios results are routed back
|
|
/// through the [`TerminalSurface`] Unix hooks so the manager stays surface-agnostic.
|
|
///
|
|
/// NOTE: we cannot simply use the strong references (the handle arguments to this
|
|
/// wire_up fn) in the subscription callbacks because that will create a reference
|
|
/// cycle. Instead, we use weak handles and upgrade them lazily.
|
|
#[cfg(unix)]
|
|
fn wire_up_terminal_attribute_poller_with_surface<S: TerminalSurface>(
|
|
terminal_attributes_poller: &ModelHandle<TerminalAttributesPoller>,
|
|
surface: &ViewHandle<S>,
|
|
model_events: &ModelHandle<ModelEventDispatcher>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
ctx: &mut ModelContext<Box<dyn TerminalManagerTrait>>,
|
|
) where
|
|
<S as Entity>::Event: PtyIntentEvent,
|
|
{
|
|
let poller_weak_handle = terminal_attributes_poller.downgrade();
|
|
let poller_weak_handle_for_termios = poller_weak_handle.clone();
|
|
let surface_weak_handle = surface.downgrade();
|
|
let surface_weak_handle_for_termios = surface_weak_handle.clone();
|
|
|
|
// Tracks the index of the started block across the model-event <-> poller interactions.
|
|
let block_index: Rc<RefCell<Option<BlockIndex>>> = Rc::new(RefCell::new(None));
|
|
let block_index_for_termios = block_index.clone();
|
|
|
|
// On block start, record the active block index and ask the surface whether
|
|
// password-prompt polling is useful before starting the poller. On block
|
|
// completion, stop polling and let the surface react to the completed block.
|
|
ctx.subscribe_to_model(model_events, move |_manager, _dispatcher, event, ctx| {
|
|
let Some(poller) = poller_weak_handle.upgrade(ctx) else {
|
|
return;
|
|
};
|
|
|
|
match event {
|
|
TerminalModelEvent::AfterBlockStarted {
|
|
command,
|
|
is_for_in_band_command: false,
|
|
..
|
|
} => {
|
|
let should_poll = surface_weak_handle.upgrade(ctx).is_some_and(|surface| {
|
|
surface.read(ctx, |surface, ctx| {
|
|
surface.should_start_password_prompt_polling(command, ctx)
|
|
})
|
|
});
|
|
if should_poll {
|
|
*block_index.borrow_mut() =
|
|
Some(model.lock().block_list().active_block_index());
|
|
poller.update(ctx, |poller, ctx| {
|
|
poller.start_polling(ctx);
|
|
});
|
|
} else {
|
|
*block_index.borrow_mut() = None;
|
|
}
|
|
}
|
|
TerminalModelEvent::AfterBlockCompleted(completed) => {
|
|
if let Some(surface) = surface_weak_handle.upgrade(ctx) {
|
|
let should_stop = surface.read(ctx, |surface, _ctx| {
|
|
surface.should_stop_password_prompt_polling(completed)
|
|
});
|
|
if should_stop {
|
|
poller.update(ctx, |poller, _ctx| {
|
|
poller.stop_polling();
|
|
});
|
|
surface.update(ctx, |surface, ctx| {
|
|
surface.on_polled_block_completed(completed, ctx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
// When the poller detects termios consistent with a password prompt, route the
|
|
// result through the surface and stop after the first detection to preserve
|
|
// today's one-notification-per-command behavior.
|
|
ctx.subscribe_to_model(
|
|
terminal_attributes_poller,
|
|
move |_manager, _poller, event, ctx| {
|
|
let TerminalAttributesPollerEvent::TermiosQueryFinished { termios } = event;
|
|
|
|
// A PTY likely has a password prompt if ECHO is disabled but ICANON is
|
|
// still enabled. Apps like neovim disable both in raw mode, so requiring
|
|
// ICANON avoids false positives.
|
|
let might_be_password_prompt = !termios.local_flags.contains(LocalFlags::ECHO)
|
|
&& termios.local_flags.contains(LocalFlags::ICANON);
|
|
if !might_be_password_prompt {
|
|
return;
|
|
}
|
|
|
|
let Some(surface) = surface_weak_handle_for_termios.upgrade(ctx) else {
|
|
return;
|
|
};
|
|
let block_index = block_index_for_termios.borrow_mut().take();
|
|
surface.update(ctx, |surface, ctx| {
|
|
surface.on_possible_password_prompt(block_index, ctx);
|
|
});
|
|
|
|
if let Some(poller) = poller_weak_handle_for_termios.upgrade(ctx) {
|
|
poller.update(ctx, |poller, _ctx| {
|
|
poller.stop_polling();
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn get_shell_starter(
|
|
chosen_shell: Option<AvailableShell>,
|
|
auth_state: &AuthState,
|
|
ctx: &mut AppContext,
|
|
) -> Option<ShellStarter> {
|
|
let preferred_shell = chosen_shell.unwrap_or_else(|| {
|
|
AvailableShells::handle(ctx).read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx))
|
|
});
|
|
let shell_starter_or_wsl_name = ShellStarter::init(preferred_shell);
|
|
|
|
// TODO(alokedesai): Further refactor this function to make it clear that it's expensive.
|
|
shell_starter_or_wsl_name
|
|
.and_then(|starter| {
|
|
galaxyui::r#async::block_on(async { starter.to_shell_starter_source().await })
|
|
})
|
|
.map(|starter_source| {
|
|
get_shell_starter_internal(
|
|
starter_source,
|
|
ctx.background_executor().clone(),
|
|
auth_state,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn get_shell_starter_internal(
|
|
shell_starter_source: ShellStarterSource,
|
|
background_executor: Arc<Background>,
|
|
auth_state: &AuthState,
|
|
) -> ShellStarter {
|
|
match shell_starter_source {
|
|
ShellStarterSource::Override(shell_starter) => shell_starter,
|
|
ShellStarterSource::Environment(starter) | ShellStarterSource::UserDefault(starter) => {
|
|
ShellStarter::Direct(starter)
|
|
}
|
|
ShellStarterSource::Fallback {
|
|
unsupported_shell,
|
|
starter,
|
|
} => {
|
|
if let Some(unsupported_shell) = unsupported_shell {
|
|
send_telemetry_on_executor!(
|
|
auth_state,
|
|
TelemetryEvent::UnsupportedShell {
|
|
shell: unsupported_shell
|
|
},
|
|
background_executor
|
|
);
|
|
}
|
|
|
|
ShellStarter::Direct(starter)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EventLoopSender for mio_channel::Sender<Message> {
|
|
fn send(&self, message: Message) -> Result<(), EventLoopSendError> {
|
|
self.send(message).map_err(|error| match error {
|
|
SendError(_) => EventLoopSendError::Disconnected,
|
|
})
|
|
}
|
|
}
|