Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
#[cfg_attr(unix, path = "unix.rs")]
|
||||
#[cfg_attr(windows, path = "windows.rs")]
|
||||
mod imp;
|
||||
|
||||
use crate::terminal::model::session::{BootstrapSessionType, SessionInfo};
|
||||
use crate::terminal::shell::ShellType;
|
||||
pub use imp::TempBootstrapFile;
|
||||
|
||||
/// Creates a `NamedTempFile` with the given bootstrap contents
|
||||
///
|
||||
/// Return `None` if any part of the operation fails
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn create_bootstrap_file<C, S>(
|
||||
contents: C,
|
||||
shell_type: ShellType,
|
||||
_wsl_distribution: Option<S>,
|
||||
) -> Option<TempBootstrapFile>
|
||||
where
|
||||
C: AsRef<[u8]>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut builder = tempfile::Builder::new();
|
||||
// PowerShell will only source a file with the "ps1" extension.
|
||||
if shell_type == ShellType::PowerShell {
|
||||
builder.suffix(".ps1");
|
||||
}
|
||||
|
||||
match TempBootstrapFile::new(
|
||||
builder,
|
||||
contents,
|
||||
#[cfg(windows)]
|
||||
_wsl_distribution,
|
||||
) {
|
||||
Ok(bootstrap_file) => Some(bootstrap_file),
|
||||
Err(err) => {
|
||||
log::warn!("Error when creating temporary bootstrap file: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks to see if we should be using a permanent bootstrap file given the
|
||||
/// session info. If so, returns a path to the permanent bootstrap file for the
|
||||
/// given shell type in bytes, if it exists.
|
||||
pub fn permanent_bootstrap_file(
|
||||
shell_type: ShellType,
|
||||
pending_session_info: &SessionInfo,
|
||||
) -> Option<Vec<u8>> {
|
||||
if pending_session_info.session_type != BootstrapSessionType::Local {
|
||||
return None;
|
||||
}
|
||||
|
||||
imp::path_to_permanent_bootstrap_file(shell_type)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
/// Represents a temporary file used for bootstrap.
|
||||
pub struct TempBootstrapFile {
|
||||
temp_file: NamedTempFile,
|
||||
}
|
||||
|
||||
impl TempBootstrapFile {
|
||||
pub fn new<C>(builder: tempfile::Builder, contents: C) -> std::io::Result<Self>
|
||||
where
|
||||
C: AsRef<[u8]>,
|
||||
{
|
||||
let mut file = builder.tempfile()?;
|
||||
file.write_all(contents.as_ref())?;
|
||||
file.flush()?;
|
||||
Ok(Self { temp_file: file })
|
||||
}
|
||||
|
||||
pub fn path_as_bytes(&self) -> Option<Vec<u8>> {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
Some(
|
||||
self.temp_file
|
||||
.path()
|
||||
.to_path_buf()
|
||||
.into_os_string()
|
||||
.into_vec(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<NamedTempFile> for TempBootstrapFile {
|
||||
type Error = tempfile::PersistError;
|
||||
|
||||
fn try_from(temp_file: NamedTempFile) -> Result<Self, Self::Error> {
|
||||
Ok(Self { temp_file })
|
||||
}
|
||||
}
|
||||
|
||||
/// We don't currently create a permanent bootstrap file on Unix-like systems.
|
||||
pub fn path_to_permanent_bootstrap_file(_shell_type: ShellType) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use typed_path::TypedPath;
|
||||
use warp_util::path::{convert_wsl_to_windows_host_path, WSLPathConversionError};
|
||||
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
/// Represents a temporary file used for bootstrap.
|
||||
/// This is necessary because [`tempfile`] requires us to hold on to the file handle. However,
|
||||
/// this causes Windows to prevent the shell from reading the file (since it is being used by
|
||||
/// another process).
|
||||
#[derive(Debug)]
|
||||
pub struct TempBootstrapFile {
|
||||
file_path: FilePath,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum FilePath {
|
||||
Direct(PathBuf),
|
||||
Wsl(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum TempBootstrapFileError {
|
||||
#[error(transparent)]
|
||||
ConvertPath(#[from] WSLPathConversionError),
|
||||
#[error("could not create temporary file")]
|
||||
Create(std::io::Error),
|
||||
#[error("could not write bootstrap script to temporary file")]
|
||||
Write(std::io::Error),
|
||||
#[error("could not flush to temporary file")]
|
||||
Flush(std::io::Error),
|
||||
#[error("could not persist temporary file")]
|
||||
Persist(#[from] tempfile::PersistError),
|
||||
}
|
||||
|
||||
impl TempBootstrapFile {
|
||||
pub fn new<C, S>(
|
||||
builder: tempfile::Builder,
|
||||
contents: C,
|
||||
wsl_distribution: Option<S>,
|
||||
) -> Result<Self, TempBootstrapFileError>
|
||||
where
|
||||
C: AsRef<[u8]>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut file = match &wsl_distribution {
|
||||
Some(distro_name) => {
|
||||
let wsl_temp_dir = convert_wsl_to_windows_host_path(
|
||||
&TypedPath::from("/tmp"),
|
||||
distro_name.as_ref(),
|
||||
)?;
|
||||
builder
|
||||
.tempfile_in(wsl_temp_dir)
|
||||
.map_err(TempBootstrapFileError::Create)?
|
||||
}
|
||||
None => builder.tempfile().map_err(TempBootstrapFileError::Create)?,
|
||||
};
|
||||
file.write_all(contents.as_ref())
|
||||
.map_err(TempBootstrapFileError::Write)?;
|
||||
file.flush().map_err(TempBootstrapFileError::Flush)?;
|
||||
|
||||
// We persist the file here because there are issues with using NamedTempFile
|
||||
// as-is. If we hold on to the file handle without persisting, Windows
|
||||
// does not let the shell access the file.
|
||||
file.keep()
|
||||
.map(|(_file, path)| Self {
|
||||
file_path: match wsl_distribution.is_some() {
|
||||
true => FilePath::Wsl(path),
|
||||
false => FilePath::Direct(path),
|
||||
},
|
||||
})
|
||||
.map_err(TempBootstrapFileError::from)
|
||||
}
|
||||
|
||||
pub fn path_as_bytes(&self) -> Option<Vec<u8>> {
|
||||
match &self.file_path {
|
||||
FilePath::Direct(path) => path
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.map(|path_str| path_str.as_bytes().to_vec()),
|
||||
FilePath::Wsl(path) => {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|file_name| file_name.to_string_lossy())?;
|
||||
// We need to source the path from the Linux distribution's perspective here.
|
||||
let path = format!("/tmp/{file_name}");
|
||||
Some(path.as_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempBootstrapFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = match &self.file_path {
|
||||
FilePath::Direct(path) => std::fs::remove_file(path),
|
||||
FilePath::Wsl(path) => std::fs::remove_file(path),
|
||||
} {
|
||||
log::warn!("Unable to remove temporary bootstrap file: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the permanent bootstrap file in bytes, if it exists.
|
||||
///
|
||||
/// Currently we only create a permanent bootstrap file for PowerShell, located
|
||||
/// alongside the Warp executable.
|
||||
pub fn path_to_permanent_bootstrap_file(shell_type: ShellType) -> Option<Vec<u8>> {
|
||||
if shell_type != ShellType::PowerShell {
|
||||
return None;
|
||||
}
|
||||
|
||||
let install_dir = crate::util::windows::install_dir().ok()?;
|
||||
let path = install_dir.join("pwsh.ps1");
|
||||
|
||||
path.is_file()
|
||||
.then(|| path.as_os_str().to_str().map(|s| s.as_bytes().to_vec()))
|
||||
.flatten()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::{AppContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::persistence::StartedCommandMetadata;
|
||||
use crate::terminal::{view::ExecuteCommandEvent, TerminalModel};
|
||||
use crate::terminal::{History, HistoryEntry};
|
||||
use crate::{persistence::ModelEvent, terminal::model::session::Sessions};
|
||||
|
||||
pub fn update_command_history(
|
||||
event: &ExecuteCommandEvent,
|
||||
model: &Arc<FairMutex<TerminalModel>>,
|
||||
model_event_sender: Option<&SyncSender<ModelEvent>>,
|
||||
sessions: &ModelHandle<Sessions>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let model = model.lock();
|
||||
let active_block = model.block_list().active_block();
|
||||
let session_id = event.session_id;
|
||||
let Some(session) = sessions.as_ref(ctx).get(session_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let shell = session.shell();
|
||||
if !shell.should_add_command_to_history(&event.command) {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_agent_executed = event.source.is_ai_command();
|
||||
|
||||
let session_ref = &*session;
|
||||
History::handle(ctx).update(ctx, move |history, _| {
|
||||
history.append_commands(
|
||||
session_id,
|
||||
vec![HistoryEntry::for_session_command(
|
||||
event.command.to_string(),
|
||||
active_block,
|
||||
session_ref,
|
||||
event.workflow_id.to_owned(),
|
||||
event.workflow_command.to_owned(),
|
||||
is_agent_executed,
|
||||
)],
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(sender) = model_event_sender {
|
||||
let sender_clone = sender.clone();
|
||||
let insert_command_event = ModelEvent::InsertCommand {
|
||||
metadata: StartedCommandMetadata {
|
||||
command: event.command.to_owned(),
|
||||
start_ts: active_block.start_ts().copied(),
|
||||
pwd: active_block.pwd().map(|pwd| pwd.to_owned()),
|
||||
shell: Some(session.shell().shell_type().name().to_owned()),
|
||||
username: Some(session.user().to_owned()),
|
||||
hostname: Some(session.hostname().to_owned()),
|
||||
session_id: Some(session_id),
|
||||
cloud_workflow_id: event.workflow_id.to_owned(),
|
||||
workflow_command: event.workflow_command.to_owned(),
|
||||
git_branch: active_block
|
||||
.git_branch()
|
||||
.map(|git_branch| git_branch.to_owned()),
|
||||
is_agent_executed,
|
||||
},
|
||||
};
|
||||
ctx.background_executor()
|
||||
.spawn(async move {
|
||||
// Sending over a sync sender can block the current thread, so we do this async.
|
||||
if let Err(e) = sender_clone.send(insert_command_event) {
|
||||
log::error!("Error sending ModelEvent: {e:?}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::terminal::SizeInfo;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Messages that may be sent to the `EventLoop`.
|
||||
#[derive(Debug)]
|
||||
pub enum Message {
|
||||
/// Data that should be written to the PTY.
|
||||
Input(Cow<'static, [u8]>),
|
||||
|
||||
/// Indicates that the `EventLoop` should be shut down.
|
||||
Shutdown,
|
||||
|
||||
/// Indicates that the child process has exited.
|
||||
///
|
||||
/// Only used on Windows, as we need to pass this information to the
|
||||
/// event loop via the channel (and cannot use the child event token).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
ChildExited,
|
||||
|
||||
/// Instruction to resize the PTY.
|
||||
Resize(SizeInfo),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod bootstrap_file;
|
||||
pub mod command_history;
|
||||
mod message;
|
||||
pub mod pty_controller;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod remote_server_controller;
|
||||
pub mod terminal_manager_util;
|
||||
|
||||
pub use message::Message;
|
||||
pub use pty_controller::{PtyController, PtyControllerEvent};
|
||||
@@ -0,0 +1,856 @@
|
||||
use std::{borrow::Cow, collections::VecDeque, sync::Arc};
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use parking_lot::FairMutex;
|
||||
use thiserror::Error;
|
||||
use warpui::r#async::block_on;
|
||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::AIAgentPtyWriteMode;
|
||||
use crate::terminal::input::CommandExecutionSource;
|
||||
use crate::terminal::model::completions::ShellCompletion;
|
||||
use crate::terminal::model::session::{
|
||||
ExecutorCommandEvent, InBandCommandCancelledEvent, Sessions,
|
||||
};
|
||||
use crate::terminal::model::tmux::commands::TmuxCommand;
|
||||
use crate::terminal::model_events::AnsiHandlerEvent;
|
||||
use crate::terminal::view::LINEFEED_REGEX;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::terminal::writeable_pty::bootstrap_file::{permanent_bootstrap_file, TempBootstrapFile};
|
||||
use crate::terminal::{
|
||||
bootstrap,
|
||||
line_editor_status::{LineEditorStatus, LineEditorStatusEvent},
|
||||
model::{ansi::Handler, escape_sequences, session::SessionInfo},
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
shell::ShellType,
|
||||
SizeUpdate, TerminalModel,
|
||||
};
|
||||
use crate::SessionSettings;
|
||||
|
||||
use super::Message;
|
||||
|
||||
/// Byte sequence to emulate the user pressing ENTER, used to execute a command in the shell.
|
||||
const COMMAND_ENTER: &[u8] = &[escape_sequences::C0::CR, escape_sequences::C0::LF];
|
||||
/// Used to let the shell know we are switching to the PS1 prompt via a bindkey \ep. This will
|
||||
/// restore the PS1 from the saved PS1 value (we had unset the PS1 for Warp prompt).
|
||||
const SWITCH_TO_PS1_ESCAPE_SEQUENCE: &[u8] = &[escape_sequences::C0::ESC, b'p'];
|
||||
/// Used to let the shell know we are switching to the Warp prompt via a bindkey \ew. This will
|
||||
/// unset the PS1 to ensure we don't have a double prompt (PS1 and Warp prompt).
|
||||
const SWITCH_TO_WARP_PROMPT_ESCAPE_SEQUENCE: &[u8] = &[escape_sequences::C0::ESC, b'w'];
|
||||
|
||||
/// Represents a single call to write bytes to the PTY asynchronously.
|
||||
enum PtyWrite {
|
||||
Command {
|
||||
command: String,
|
||||
shell_type: ShellType,
|
||||
/// The id if the command is an in-band command or `None` if the command is not an in-band
|
||||
/// command.
|
||||
in_band_command_id: Option<String>,
|
||||
/// If 'some', the given callback is called right before the bytes are written to the PTY.
|
||||
before_write_fn: Option<Box<dyn Fn() + Send + 'static>>,
|
||||
},
|
||||
Bytes {
|
||||
/// The bytes to be written.
|
||||
bytes: Cow<'static, [u8]>,
|
||||
},
|
||||
AgentInput {
|
||||
/// The bytes to be written.
|
||||
bytes: Cow<'static, [u8]>,
|
||||
/// The `mode` for the agent's write.
|
||||
mode: AIAgentPtyWriteMode,
|
||||
},
|
||||
TmuxCommand(TmuxCommand),
|
||||
RunNativeShellCompletions(NativeShellCompletionsState),
|
||||
}
|
||||
|
||||
enum NativeShellCompletionsState {
|
||||
AwaitingPrompt {
|
||||
buffer_text: String,
|
||||
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
|
||||
},
|
||||
AwaitingResults {
|
||||
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl NativeShellCompletionsState {
|
||||
fn is_awaiting_prompt(&self) -> bool {
|
||||
matches!(self, Self::AwaitingPrompt { .. })
|
||||
}
|
||||
}
|
||||
|
||||
enum TmuxControlMode {
|
||||
/// Tmux control mode is started, but we don't have the primary pane yet.
|
||||
Pending { buffer: Vec<u8> },
|
||||
/// Tmux control mode is active.
|
||||
Active { primary_pane: u32 },
|
||||
}
|
||||
|
||||
/// Controller for writes to the PTY.
|
||||
///
|
||||
/// This is responsible for coordinating writes to the PTY amongst input like user commands, non-command user
|
||||
/// input, and in-band commands in conjunction with line editor status.
|
||||
pub struct PtyController<T: EventLoopSender> {
|
||||
/// `Sender` for the main PTY event loop channel.
|
||||
event_loop_tx: T,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
line_editor_status: ModelHandle<LineEditorStatus>,
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
pending_writes: VecDeque<PtyWrite>,
|
||||
is_user_command_executing: bool,
|
||||
is_bracketed_paste_enabled: bool,
|
||||
/// If we're bootstrapping the shell by sourcing a file with the bootstrap
|
||||
/// script, this will hold the handle to the file. Once bootstrapping is
|
||||
/// complete, it will be dropped to clean up the temporary file.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
bootstrap_file: Option<TempBootstrapFile>,
|
||||
tmux_control_mode: Option<TmuxControlMode>,
|
||||
in_flight_native_completions_state: Option<NativeShellCompletionsState>,
|
||||
}
|
||||
|
||||
impl<T: EventLoopSender> PtyController<T> {
|
||||
pub fn new(
|
||||
event_loop_tx: T,
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
line_editor_status: ModelHandle<LineEditorStatus>,
|
||||
sessions: ModelHandle<Sessions>,
|
||||
executor_command_rx: Receiver<ExecutorCommandEvent>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| match event {
|
||||
ModelEvent::Handler(AnsiHandlerEvent::UserCommandFinished) => {
|
||||
me.is_user_command_executing = false;
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::InitShell {
|
||||
pending_session_info,
|
||||
}) => {
|
||||
me.initialize_shell(pending_session_info.as_ref(), ctx);
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::Bootstrapped { is_subshell, .. }) => {
|
||||
me.shell_bootstrapped(*is_subshell);
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::SetBracketedPaste) => {
|
||||
me.is_bracketed_paste_enabled = true;
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::UnsetBracketedPaste) => {
|
||||
me.is_bracketed_paste_enabled = false;
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::StartTmuxControlMode) => {
|
||||
me.tmux_control_mode = Some(TmuxControlMode::Pending {
|
||||
buffer: Default::default(),
|
||||
});
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::RunTmuxCommand(command)) => {
|
||||
me.send_write_to_event_loop(PtyWrite::TmuxCommand(command.to_owned()), ctx);
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::EndTmuxControlMode) => {
|
||||
me.tmux_control_mode = None;
|
||||
}
|
||||
ModelEvent::HonorPS1OutOfSync => {
|
||||
// We force re-sync the PS1 state of Warp settings with the shell's environment variable, $WARP_HONOR_PS1, via
|
||||
// a bindkey (which triggers a shell function).
|
||||
let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1;
|
||||
if honor_ps1 {
|
||||
me.send_switch_to_ps1_bindkey(ctx);
|
||||
} else {
|
||||
me.send_switch_to_warp_prompt_bindkey(ctx);
|
||||
}
|
||||
}
|
||||
ModelEvent::Handler(AnsiHandlerEvent::TmuxControlModeReady { primary_pane }) => {
|
||||
let previous_control_mode_state = me.tmux_control_mode.replace(TmuxControlMode::Active {
|
||||
primary_pane: *primary_pane,
|
||||
});
|
||||
if let Some(TmuxControlMode::Pending { buffer }) = previous_control_mode_state {
|
||||
me.send_write_to_event_loop(
|
||||
PtyWrite::Bytes {
|
||||
bytes: Cow::Owned(buffer),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
ModelEvent::CompletionsFinished(data) => {
|
||||
let Some(NativeShellCompletionsState::AwaitingResults { results_tx }) = me.in_flight_native_completions_state.take() else {
|
||||
log::warn!("Received CompletionsFinished event but didn't have a channel to send results over!");
|
||||
return;
|
||||
};
|
||||
let _ = block_on(results_tx.send(data.clone()));
|
||||
}
|
||||
ModelEvent::SendCompletionsPrompt => {
|
||||
let Some(NativeShellCompletionsState::AwaitingPrompt {
|
||||
buffer_text,
|
||||
results_tx,
|
||||
}) = me.in_flight_native_completions_state.take() else {
|
||||
log::warn!("Received SendCompletionsPrompt event but didn't have a prompt to send!");
|
||||
return;
|
||||
};
|
||||
me.in_flight_native_completions_state = Some(NativeShellCompletionsState::AwaitingResults { results_tx });
|
||||
|
||||
let mut bytes = buffer_text.into_bytes();
|
||||
// We use the EOT character to signal the end of the prompt.
|
||||
bytes.push(escape_sequences::C0::EOT);
|
||||
|
||||
// We send the write directly to the event loop without
|
||||
// queueing, as we currently have exclusive control over pty
|
||||
// writes.
|
||||
me.send_write_to_event_loop(
|
||||
PtyWrite::Bytes {
|
||||
bytes: bytes.into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Now that we've provided the prompt, we can start executing
|
||||
// other queued writes.
|
||||
me.execute_next_queued_write(ctx);
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&line_editor_status, |me, event, ctx| {
|
||||
if let LineEditorStatusEvent::Active = event {
|
||||
let input_reporting_seq = me
|
||||
.model_event_dispatcher
|
||||
.as_ref(ctx)
|
||||
.active_session_id()
|
||||
.and_then(|id| me.sessions.as_ref(ctx).get(id))
|
||||
.and_then(|session| session.shell().input_reporting_sequence());
|
||||
if let Some(bytes) = input_reporting_seq {
|
||||
me.pending_writes.push_front(PtyWrite::Bytes {
|
||||
bytes: Cow::Owned(bytes.to_vec()),
|
||||
});
|
||||
}
|
||||
me.execute_next_queued_write(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = ctx.spawn_stream_local(
|
||||
executor_command_rx,
|
||||
|me, event, ctx| match event {
|
||||
ExecutorCommandEvent::ExecuteCommand { command, cancel_tx } => {
|
||||
me.queue_in_band_command(
|
||||
command.command.as_str(),
|
||||
command.shell_type,
|
||||
command.command_id,
|
||||
cancel_tx,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
ExecutorCommandEvent::CancelCommand { id } => {
|
||||
me.cancel_in_band_command(id.as_str());
|
||||
}
|
||||
ExecutorCommandEvent::ExecuteTmuxCommand(command) => {
|
||||
me.send_write_to_event_loop(PtyWrite::TmuxCommand(command), ctx);
|
||||
}
|
||||
},
|
||||
|_, _| (),
|
||||
);
|
||||
|
||||
Self {
|
||||
event_loop_tx,
|
||||
terminal_model,
|
||||
line_editor_status,
|
||||
sessions,
|
||||
model_event_dispatcher,
|
||||
pending_writes: VecDeque::new(),
|
||||
is_user_command_executing: false,
|
||||
is_bracketed_paste_enabled: false,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
bootstrap_file: None,
|
||||
tmux_control_mode: None,
|
||||
in_flight_native_completions_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.pending_writes.push_back(PtyWrite::Bytes {
|
||||
bytes: SWITCH_TO_PS1_ESCAPE_SEQUENCE.into(),
|
||||
});
|
||||
self.execute_next_queued_write(ctx);
|
||||
|
||||
let is_bash_shell = self
|
||||
.model_event_dispatcher
|
||||
.as_ref(ctx)
|
||||
.active_session_id()
|
||||
.and_then(|id| self.sessions.as_ref(ctx).get(id))
|
||||
.map(|session| session.shell().shell_type() == ShellType::Bash)
|
||||
.unwrap_or(false);
|
||||
if is_bash_shell {
|
||||
// We cannot repaint via shell command in bash, so we must execute an empty command to force refresh the prompt instantly
|
||||
// (avoid a 1 block delay since the current prompt has technically already been sent).
|
||||
self.pending_writes.push_back(PtyWrite::Bytes {
|
||||
bytes: COMMAND_ENTER.into(),
|
||||
});
|
||||
self.execute_next_queued_write(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(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.pending_writes.push_back(PtyWrite::Bytes {
|
||||
bytes: SWITCH_TO_WARP_PROMPT_ESCAPE_SEQUENCE.into(),
|
||||
});
|
||||
self.execute_next_queued_write(ctx);
|
||||
|
||||
let is_bash_shell = self
|
||||
.model_event_dispatcher
|
||||
.as_ref(ctx)
|
||||
.active_session_id()
|
||||
.and_then(|id| self.sessions.as_ref(ctx).get(id))
|
||||
.map(|session| session.shell().shell_type() == ShellType::Bash)
|
||||
.unwrap_or(false);
|
||||
if is_bash_shell {
|
||||
// We cannot repaint via shell command in bash, so we must execute an empty command to force refresh the prompt instantly
|
||||
// (avoid a 1 block delay since the current prompt has technically already been sent).
|
||||
self.pending_writes.push_back(PtyWrite::Bytes {
|
||||
bytes: COMMAND_ENTER.into(),
|
||||
});
|
||||
self.execute_next_queued_write(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_in_band_command(&mut self, command_id: &str) {
|
||||
self.pending_writes.retain(|pty_write| {
|
||||
!matches!(pty_write, PtyWrite::Command {
|
||||
in_band_command_id, ..
|
||||
} if in_band_command_id.as_deref() == Some(command_id))
|
||||
});
|
||||
}
|
||||
|
||||
/// Queues an in-band command to be written to the PTY, either immediately, or when the line
|
||||
/// editor next becomes active.
|
||||
///
|
||||
/// If a user command is currently executing, this short-circuits since the in-band command
|
||||
/// request is likely stale. However, we still need to signal that the command will not be
|
||||
/// executed so the executor knows to clear it.
|
||||
fn queue_in_band_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
shell_type: ShellType,
|
||||
command_id: String,
|
||||
cancel_tx: Sender<InBandCommandCancelledEvent>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.is_user_command_executing {
|
||||
// Send blocking should be okay b/c this is an unbound channel
|
||||
if let Err(err) = block_on(cancel_tx.send(InBandCommandCancelledEvent { command_id })) {
|
||||
log::warn!("Pty Controller failed to cancel in band command: {err:?}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let terminal_model = self.terminal_model.clone();
|
||||
self.pending_writes.push_back(PtyWrite::Command {
|
||||
command: command.to_owned(),
|
||||
shell_type,
|
||||
in_band_command_id: Some(command_id),
|
||||
before_write_fn: Some(Box::new(move || {
|
||||
let mut terminal_model = terminal_model.lock();
|
||||
terminal_model
|
||||
.block_list_mut()
|
||||
.start_active_block_for_in_band_command();
|
||||
})),
|
||||
});
|
||||
|
||||
self.execute_next_queued_write(ctx);
|
||||
}
|
||||
|
||||
/// Returns whether we can currently write to the pty, or if we need to
|
||||
/// enqueue writes for later.
|
||||
fn can_write_to_pty(&self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
self.line_editor_status.as_ref(ctx).is_line_editor_active()
|
||||
// If we're in the middle of a native completions request, we should not send any more
|
||||
// writes to the shell until we've sent the string to complete.
|
||||
&& !self.in_flight_native_completions_state.as_ref().is_some_and(|state| state.is_awaiting_prompt())
|
||||
}
|
||||
|
||||
/// Executes the next queued `PtyWrite`, if able.
|
||||
///
|
||||
/// This is a no-op if the line editor is currently inactive; in the constructor of
|
||||
/// PtyController, a subscription is registered on `LineEditorStatus` which calls this function
|
||||
/// when the line editor becomes active.
|
||||
fn execute_next_queued_write(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !self.can_write_to_pty(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(write) = self.pending_writes.pop_front() {
|
||||
let is_command = matches!(write, PtyWrite::Command { .. });
|
||||
self.send_write_to_event_loop(write, ctx);
|
||||
if !is_command {
|
||||
self.execute_next_queued_write(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a set of bytes to the PTY to begin bootstrapping a shell.
|
||||
pub(super) fn initialize_shell(
|
||||
&mut self,
|
||||
pending_session_info: &SessionInfo,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let shell_type = pending_session_info.shell.shell_type();
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
if let Some(path) = permanent_bootstrap_file(shell_type, pending_session_info) {
|
||||
// If there is a permanent bootstrap file, source it directly. We
|
||||
// currently only do this for local PowerShell sessions on Windows.
|
||||
self.source_bootstrap_script(path, shell_type, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let bootstrap = bootstrap::script_for_shell(shell_type, &crate::ASSETS);
|
||||
self.write_bootstrap_script_to_shell(pending_session_info, ctx, shell_type, bootstrap);
|
||||
}
|
||||
|
||||
/// Writes the bytes to to terminate and run the bootstrap script.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn write_terminating_bootstrap_bytes(&mut self, ctx: &mut ModelContext<PtyController<T>>) {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(unix)] {
|
||||
self.write_bytes(&b"\n"[..], ctx);
|
||||
} else if #[cfg(target_os = "windows")] {
|
||||
self.write_bytes(&b"\r"[..], ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn write_bootstrap_script_to_shell(
|
||||
&mut self,
|
||||
pending_session_info: &SessionInfo,
|
||||
ctx: &mut ModelContext<PtyController<T>>,
|
||||
shell_type: ShellType,
|
||||
bootstrap: Cow<'static, [u8]>,
|
||||
) {
|
||||
use super::bootstrap_file::create_bootstrap_file;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
|
||||
if bootstrap::should_use_rc_file_bootstrap_method(shell_type, pending_session_info) {
|
||||
let wsl_distribution = match (
|
||||
&pending_session_info.launch_data,
|
||||
pending_session_info.wsl_name.as_ref(),
|
||||
) {
|
||||
(_, Some(wsl_name)) => Some(wsl_name),
|
||||
(Some(ShellLaunchData::WSL { distro }), _) => Some(distro),
|
||||
(
|
||||
Some(ShellLaunchData::Executable { .. })
|
||||
| Some(ShellLaunchData::MSYS2 { .. })
|
||||
| Some(ShellLaunchData::DockerSandbox { .. })
|
||||
| None,
|
||||
_,
|
||||
) => None,
|
||||
};
|
||||
// If creating the temporary file fails for any reason, we fall
|
||||
// back to the existing bracketed paste logic. Using bracketed paste
|
||||
// reduces the amount of reformatting that Fish tries to do and so improves
|
||||
// bootstrap speed. We need to add an explicit leading space, since Fish
|
||||
// automatically trims the input when performing a bracketed paste.
|
||||
if let Some(file) = create_bootstrap_file(&bootstrap, shell_type, wsl_distribution) {
|
||||
if let Some(path) = file.path_as_bytes() {
|
||||
self.source_bootstrap_script(path, shell_type, ctx);
|
||||
} else {
|
||||
self.write_terminating_bootstrap_bytes(ctx);
|
||||
log::error!("Could not convert bootstrap script file path to str");
|
||||
}
|
||||
|
||||
self.bootstrap_file = Some(file);
|
||||
} else {
|
||||
self.write_bytes(&b" "[..], ctx);
|
||||
self.write_bytes(escape_sequences::BRACKETED_PASTE_START, ctx);
|
||||
self.write_bytes(bootstrap, ctx);
|
||||
self.write_bytes(escape_sequences::BRACKETED_PASTE_END, ctx);
|
||||
self.write_terminating_bootstrap_bytes(ctx);
|
||||
}
|
||||
} else {
|
||||
self.write_bytes(bootstrap, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
/// Sources the bootstrap script at the given path. Assumes that the path
|
||||
/// contains a valid file.
|
||||
fn source_bootstrap_script(
|
||||
&mut self,
|
||||
path_to_script: Vec<u8>,
|
||||
shell_type: ShellType,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
use warp_util::path::ShellFamily;
|
||||
|
||||
// TODO(CORE-2099): Figure out a more robust solution here. Fish users
|
||||
// can redefine these functions via fish functions. Ideally this won't
|
||||
// break if the user redefines the `source` or `.` built-in.
|
||||
match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
let path_str = String::from_utf8_lossy(&path_to_script);
|
||||
let escaped = ShellFamily::PowerShell.escape(&path_str).into_owned();
|
||||
self.write_bytes(b" . ", ctx);
|
||||
self.write_bytes(escaped.into_bytes(), ctx);
|
||||
}
|
||||
_ => {
|
||||
self.write_bytes(b" source '", ctx);
|
||||
self.write_bytes(path_to_script, ctx);
|
||||
self.write_bytes(b"'", ctx);
|
||||
}
|
||||
}
|
||||
self.write_terminating_bootstrap_bytes(ctx);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn write_bootstrap_script_to_shell(
|
||||
&mut self,
|
||||
_pending_session_info: &SessionInfo,
|
||||
ctx: &mut ModelContext<PtyController<T>>,
|
||||
_shell_type: ShellType,
|
||||
bootstrap: Cow<'static, [u8]>,
|
||||
) {
|
||||
self.write_bytes(bootstrap, ctx);
|
||||
}
|
||||
|
||||
/// Handles the shell having finished bootstrapping.
|
||||
fn shell_bootstrapped(&mut self, is_subshell: bool) {
|
||||
if is_subshell {
|
||||
self.is_user_command_executing = false;
|
||||
}
|
||||
|
||||
// Now that we have bootstrapped, we can be sure that the bootstrap
|
||||
// file is no longer needed.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
self.bootstrap_file.take();
|
||||
}
|
||||
|
||||
/// Converts the given `command` into a byte array and writes its corresponding bytes to the PTY.
|
||||
///
|
||||
/// If the line editor is active, the command is written immediately. Otherwise, the command is
|
||||
/// written when the line editor becomes active.
|
||||
///
|
||||
/// This also clears pending_writes, since the priority is to execute the user's command.
|
||||
///
|
||||
/// The exact sequence of corresponding bytes depends on the given `shell`. For example, if the
|
||||
/// shell supports bracketed paste, the command's bytes may be wrapped in bracketed paste byte
|
||||
/// sequences.
|
||||
pub fn write_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
shell_type: ShellType,
|
||||
source: CommandExecutionSource,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
{
|
||||
let mut model = self.terminal_model.lock();
|
||||
|
||||
// Explicitly start the block now that the command is executed.
|
||||
match source {
|
||||
CommandExecutionSource::AI { metadata } => {
|
||||
model.start_command_execution_with_ai_metadata(metadata)
|
||||
}
|
||||
CommandExecutionSource::SharedSession {
|
||||
participant_id,
|
||||
ai_metadata,
|
||||
..
|
||||
} => model.start_command_execution_for_shared_session(participant_id, ai_metadata),
|
||||
CommandExecutionSource::User => model.start_command_execution(),
|
||||
CommandExecutionSource::EnvVarCollection { metadata } => {
|
||||
model.start_command_execution_from_env_var_collection(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that the `TerminalModel` doesn't interpret any of the PTY output from the
|
||||
// following commands as in-band command output. If the in-band command output is not
|
||||
// currently being received by the `TerminalModel`, this is a no-op.
|
||||
model.end_in_band_command_output(false);
|
||||
}
|
||||
|
||||
self.pending_writes.clear();
|
||||
self.is_user_command_executing = true;
|
||||
|
||||
// Send the write to the PTY event loop.
|
||||
let write = PtyWrite::Command {
|
||||
command: command.to_owned(),
|
||||
shell_type,
|
||||
in_band_command_id: None,
|
||||
before_write_fn: None,
|
||||
};
|
||||
if self.can_write_to_pty(ctx) {
|
||||
// Cancel the async writer task and clear the async write queue.
|
||||
// Check if line editor is active
|
||||
self.send_write_to_event_loop(write, ctx);
|
||||
} else {
|
||||
self.pending_writes.push_back(write);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously writes the EOT (End-of-Transmission) char to the PTY.
|
||||
pub fn write_end_of_transmission_char(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.write_bytes(&[escape_sequences::C0::EOT][..], ctx);
|
||||
|
||||
// Consider the active block to be "started" since a user performed an action that
|
||||
// results in bytes being written to the pty. This makes the output from ctrl-d during ssh
|
||||
// get written to the active block.
|
||||
// TODO: reconsider this behavior since the output was not the result of a command, and given the function name
|
||||
// is start_command_execution and no command was executed.
|
||||
self.terminal_model.lock().start_command_execution();
|
||||
}
|
||||
|
||||
/// Resizes the PTY's size (i.e. its notion of the number of columns and rows in the screen) via
|
||||
/// ioctl system call and updates the terminal model as appropriate.
|
||||
pub fn resize_pty(&self, size_update: SizeUpdate, ctx: &mut ModelContext<Self>) {
|
||||
// Send a message to the PTY event loop to resize the PTY.
|
||||
// We also need to resize when rows/cols changed without a pane size change
|
||||
// (e.g. ViewerSizeReported on the sharer side).
|
||||
if size_update.pane_size_changed()
|
||||
|| size_update.is_refresh()
|
||||
|| size_update.rows_or_columns_changed()
|
||||
{
|
||||
self.send_message_to_event_loop(Message::Resize(size_update.new_size), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes agent input to the PTY.
|
||||
pub fn write_agent_bytes<B: Into<Cow<'static, [u8]>>>(
|
||||
&mut self,
|
||||
bytes: B,
|
||||
mode: &AIAgentPtyWriteMode,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.send_write_to_event_loop(
|
||||
PtyWrite::AgentInput {
|
||||
bytes: bytes.into(),
|
||||
mode: *mode,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes user input to the PTY.
|
||||
///
|
||||
/// This should only be called for non-command input (e.g. input that should be passed through
|
||||
/// in a long-running command or in the alt screen, rather than from the input editor).
|
||||
pub fn write_bytes<B: Into<Cow<'static, [u8]>>>(
|
||||
&mut self,
|
||||
bytes: B,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.send_write_to_event_loop(
|
||||
PtyWrite::Bytes {
|
||||
bytes: bytes.into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shuts down the pty and event loop.
|
||||
pub fn shutdown_pty(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.send_message_to_event_loop(Message::Shutdown, ctx);
|
||||
}
|
||||
|
||||
/// Sends a message to the event loop thread requesting a PTY write for the given `bytes`.
|
||||
///
|
||||
/// If the write corresponds to a command, this also calls
|
||||
/// [`LineEditorStatus::did_execute_command()`].
|
||||
fn send_write_to_event_loop(&mut self, write: PtyWrite, ctx: &mut ModelContext<Self>) {
|
||||
let (bytes_to_write, is_for_command, on_write_fn, raw_tmux_command) = match write {
|
||||
PtyWrite::Command {
|
||||
command,
|
||||
shell_type,
|
||||
before_write_fn: on_write_fn,
|
||||
..
|
||||
} => (
|
||||
Cow::Owned(bytes_to_execute_command(
|
||||
command.as_str(),
|
||||
shell_type,
|
||||
self.is_bracketed_paste_enabled,
|
||||
)),
|
||||
true,
|
||||
on_write_fn,
|
||||
false,
|
||||
),
|
||||
PtyWrite::AgentInput { bytes, mode } => {
|
||||
let decorated_bytes =
|
||||
mode.decorate_bytes(bytes.into_owned(), self.is_bracketed_paste_enabled);
|
||||
(decorated_bytes.into(), false, None, false)
|
||||
}
|
||||
PtyWrite::Bytes { bytes } => (bytes, false, None, false),
|
||||
PtyWrite::TmuxCommand(command) => {
|
||||
let command = command.get_command_string();
|
||||
debug_assert!(
|
||||
command.ends_with('\n'),
|
||||
"Tmux commands must end in a newlines so they are executed"
|
||||
);
|
||||
debug_assert!(
|
||||
self.tmux_control_mode.is_some(),
|
||||
"Received tmux command outside of control mode."
|
||||
);
|
||||
(command.into_bytes().into(), false, None, true)
|
||||
}
|
||||
PtyWrite::RunNativeShellCompletions(state) => {
|
||||
self.in_flight_native_completions_state = Some(state);
|
||||
|
||||
// Send a ^Y control code to trigger the right bindkey. We
|
||||
// then wait for an OSC-based signal from the shell before we
|
||||
// send the text that needs to be completed.
|
||||
let bytes = vec![0x19_u8];
|
||||
(bytes.into(), false, None, false)
|
||||
}
|
||||
};
|
||||
|
||||
// The terminal hangs if we send 0 bytes through.
|
||||
if bytes_to_write.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes_to_write = match &mut self.tmux_control_mode {
|
||||
None => bytes_to_write,
|
||||
Some(_) if raw_tmux_command => bytes_to_write,
|
||||
Some(TmuxControlMode::Pending { buffer }) => {
|
||||
buffer.extend_from_slice(&bytes_to_write);
|
||||
return;
|
||||
}
|
||||
Some(TmuxControlMode::Active { primary_pane }) => {
|
||||
crate::terminal::model::tmux::format_input(*primary_pane, &bytes_to_write)
|
||||
.as_bytes()
|
||||
.to_owned()
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
if is_for_command {
|
||||
self.line_editor_status
|
||||
.update(ctx, |line_editor_status, ctx| {
|
||||
line_editor_status.did_execute_command(ctx)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(on_write_fn) = on_write_fn {
|
||||
on_write_fn();
|
||||
}
|
||||
|
||||
self.send_message_to_event_loop(Message::Input(bytes_to_write), ctx);
|
||||
}
|
||||
|
||||
/// Sends a message to the event loop. If the send fails with `SendError::Disconnected`, emits
|
||||
/// a `PtyDisconnected` event.
|
||||
fn send_message_to_event_loop(&self, message: Message, ctx: &mut ModelContext<Self>) {
|
||||
match self.event_loop_tx.send(message) {
|
||||
Err(EventLoopSendError::Disconnected) => {
|
||||
ctx.emit(PtyControllerEvent::PtyDisconnected);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Unable to send event loop msg {e:?}");
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_native_shell_completions(
|
||||
&mut self,
|
||||
buffer_text: String,
|
||||
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Make sure we only have a single pending native shell completions
|
||||
// request at a time by dropping any existing ones from the queue.
|
||||
self.pending_writes
|
||||
.retain(|write| !matches!(write, PtyWrite::RunNativeShellCompletions(_)));
|
||||
|
||||
self.pending_writes
|
||||
.push_back(PtyWrite::RunNativeShellCompletions(
|
||||
NativeShellCompletionsState::AwaitingPrompt {
|
||||
buffer_text,
|
||||
results_tx,
|
||||
},
|
||||
));
|
||||
self.execute_next_queued_write(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PtyControllerEvent {
|
||||
/// Emitted when the event loop thread has exited.
|
||||
PtyDisconnected,
|
||||
}
|
||||
|
||||
impl<T: EventLoopSender> Entity for PtyController<T> {
|
||||
type Event = PtyControllerEvent;
|
||||
}
|
||||
|
||||
/// Returns the shell-dependent array of bytes to be written to the PTY to execute `command`.
|
||||
fn bytes_to_execute_command(
|
||||
command: &str,
|
||||
shell_type: ShellType,
|
||||
is_bracketed_paste_enabled: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut command_bytes = shell_type.kill_buffer_bytes().to_vec();
|
||||
|
||||
// Only execute the command via bracketed paste if the command is not empty. Some ZSH
|
||||
// bracketed paste magic functions return errors if bracketed paste is used without text
|
||||
// in-between the bracketed paste escape sequences.
|
||||
if is_bracketed_paste_enabled && !command.is_empty() {
|
||||
match shell_type {
|
||||
ShellType::Fish => {
|
||||
// Fish strips leading (and trailing) whitespace from pasted commands (entered via
|
||||
// bracketed paste). To ensure that leading whitespace is preserved, first append
|
||||
// leading whitespace bytes and then surround the remaining command string with the
|
||||
// bracketed paste sequence. Conceptually, this would be like manually typing in
|
||||
// the whitespace into the fish line editor, and then pasting in the command.
|
||||
//
|
||||
// The leading whitespace is particularly meaningful in fish because it causes the
|
||||
// following command to be ommitted from history (like the HISTIGNORESPACE option
|
||||
// in zsh).
|
||||
//
|
||||
// We don't care about preserving trailing whitespace; it would just take up
|
||||
// unnecessary space in the blocklist.
|
||||
let (leading_whitespace, rest_of_command) =
|
||||
command.split_at(command.len() - command.trim_start().len());
|
||||
command_bytes.extend(leading_whitespace.as_bytes());
|
||||
command_bytes.extend(wrap_bytes_in_bracketed_paste(
|
||||
rest_of_command
|
||||
.replace(escape_sequences::C0::ESC as char, "")
|
||||
.into_bytes(),
|
||||
));
|
||||
}
|
||||
_ => command_bytes.extend(wrap_bytes_in_bracketed_paste(
|
||||
command
|
||||
.replace(escape_sequences::C0::ESC as char, "")
|
||||
.into_bytes(),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
let command_without_escapes = command.replace(escape_sequences::C0::ESC as char, "");
|
||||
// This is a fix for PLAT-770 to allow multi-line commands in Powershell.
|
||||
// In general, shells without bracketed paste don't handle `\n` that well,
|
||||
// and in the case of PowerShell, it is explicitly ignored.
|
||||
let command_without_newlines = LINEFEED_REGEX.replace_all(&command_without_escapes, "\r");
|
||||
command_bytes.extend(command_without_newlines.as_bytes());
|
||||
}
|
||||
command_bytes.extend(shell_type.execute_command_bytes().to_vec());
|
||||
command_bytes
|
||||
}
|
||||
|
||||
/// Returns a vector containing the given `bytes` wrapped in bracketed paste start and end
|
||||
/// sequences.
|
||||
fn wrap_bytes_in_bracketed_paste(bytes: impl IntoIterator<Item = u8>) -> impl Iterator<Item = u8> {
|
||||
escape_sequences::BRACKETED_PASTE_START
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(bytes)
|
||||
.chain(escape_sequences::BRACKETED_PASTE_END.iter().copied())
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum EventLoopSendError {
|
||||
#[error("Unable to send message: receiver is disconnected")]
|
||||
Disconnected,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub trait EventLoopSender: 'static {
|
||||
fn send(&self, message: Message) -> Result<(), EventLoopSendError>;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use crate::terminal::color::List;
|
||||
use crate::terminal::event::UserBlockCompleted;
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::block::{BlockSize, SerializedBlock};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::BlockPadding;
|
||||
use crate::theme::WarpTheme;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl PtyController {
|
||||
/// Flushes PTY writes by sending a "dummy" test write, and blocking until the test write is
|
||||
/// handled.
|
||||
///
|
||||
/// When the "dummy write" is handled, an event is emitted through a channel. This method
|
||||
/// blocks on receiving this event through the channel. Since async writes are handled in order
|
||||
/// that they are queued, this ensures all queued writes have been handled/"flushed".
|
||||
fn flush_pty_writes(&mut self) {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
let _ = self.queue_async_write(AsyncPtyWrite {
|
||||
bytes: vec![],
|
||||
delay: None,
|
||||
on_write_fn: Some(Box::new(move || {
|
||||
let _ = tx.send(());
|
||||
})),
|
||||
});
|
||||
let _ = warpui::r#async::block_on(rx);
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_model(background_executor: Arc<Background>) -> Arc<FairMutex<TerminalModel>> {
|
||||
Arc::new(FairMutex::new(TerminalModel::new_for_test(
|
||||
// This BlockSize contains arbitrary values.
|
||||
BlockSize {
|
||||
block_padding: BlockPadding {
|
||||
padding_top: 0.5,
|
||||
command_padding_top: 0.5,
|
||||
middle: 0.5,
|
||||
bottom: 0.5,
|
||||
},
|
||||
size: SizeInfo::new_for_test_with_width_and_height(7., 10.5),
|
||||
max_block_scroll_limit: 1000,
|
||||
prompt_height: 1.,
|
||||
},
|
||||
List::from(&WarpTheme::default().into()),
|
||||
ChannelEventListener::new_for_test(),
|
||||
background_executor,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
fn assert_input_matches(message: &Message, expected_bytes: Vec<u8>) {
|
||||
assert!(matches!(message, Message::Input(bytes) if bytes.to_vec() == expected_bytes));
|
||||
}
|
||||
|
||||
/// Returns a vector containing the bytes we expect to be written to the PTY to execute the given
|
||||
/// `command` in the given `Shell`.
|
||||
fn expected_command_bytes(command: &str, shell: &Shell) -> Vec<u8> {
|
||||
let mut bytes = shell.shell_type().kill_buffer_bytes().to_vec();
|
||||
bytes.extend(command.as_bytes().to_vec());
|
||||
bytes.extend(shell.shell_type().execute_command_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_writes_user_command() {
|
||||
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
let mut controller = PtyController::new(
|
||||
event_loop_tx,
|
||||
background_executor.clone(),
|
||||
terminal_model(background_executor),
|
||||
);
|
||||
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
assert!(controller.write_user_command("echo foo", &shell).is_ok());
|
||||
controller.flush_pty_writes();
|
||||
|
||||
let message = event_loop_rx
|
||||
.try_recv()
|
||||
.expect("PtyController should have sent write.");
|
||||
assert!(
|
||||
matches!(message, Message::Input(bytes) if bytes.to_vec() == expected_command_bytes("echo foo", &shell))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_writes_in_band_command() {
|
||||
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
let mut controller = PtyController::new(
|
||||
event_loop_tx,
|
||||
background_executor.clone(),
|
||||
terminal_model(background_executor),
|
||||
);
|
||||
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
|
||||
controller.flush_pty_writes();
|
||||
|
||||
let message = event_loop_rx
|
||||
.try_recv()
|
||||
.expect("PtyController should have sent write.");
|
||||
assert_input_matches(&message, expected_command_bytes("echo foo", &shell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_updates_block_list_when_writing_in_band_command() {
|
||||
let (event_loop_tx, _) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
let terminal_model = terminal_model(background_executor.clone());
|
||||
let mut controller =
|
||||
PtyController::new(event_loop_tx, background_executor, terminal_model.clone());
|
||||
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
|
||||
controller.flush_pty_writes();
|
||||
|
||||
assert!(terminal_model
|
||||
.lock()
|
||||
.block_list()
|
||||
.is_writing_or_executing_in_band_command());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_writes_input_buffer_sequence_after_block_completed() {
|
||||
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
|
||||
let mut controller = PtyController::new(
|
||||
event_loop_tx,
|
||||
background_executor.clone(),
|
||||
terminal_model(background_executor),
|
||||
);
|
||||
controller.set_state_after_block_completed(
|
||||
&BlockType::User(UserBlockCompleted {
|
||||
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
|
||||
.into(),
|
||||
command: "echo foo".to_owned(),
|
||||
output_truncated: "".to_owned(),
|
||||
started_at: None,
|
||||
num_output_lines: 0,
|
||||
num_output_lines_truncated: 0,
|
||||
shell_type: None,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
controller.flush_pty_writes();
|
||||
|
||||
let message = event_loop_rx
|
||||
.try_recv()
|
||||
.expect("PtyController should have sent write.");
|
||||
assert_input_matches(&message, vec![escape_sequences::C0::ESC, b'i']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_writes_in_band_command_after_input_buffer_sequence() {
|
||||
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
let mut controller = PtyController::new(
|
||||
event_loop_tx,
|
||||
background_executor.clone(),
|
||||
terminal_model(background_executor),
|
||||
);
|
||||
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
controller.set_state_after_block_completed(
|
||||
&BlockType::User(UserBlockCompleted {
|
||||
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
|
||||
.into(),
|
||||
command: "echo foo".to_owned(),
|
||||
output_truncated: "".to_owned(),
|
||||
started_at: None,
|
||||
num_output_lines: 0,
|
||||
num_output_lines_truncated: 0,
|
||||
shell_type: None,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
|
||||
controller.flush_pty_writes();
|
||||
|
||||
let mut messages = vec![];
|
||||
while let Ok(message) = event_loop_rx.try_recv() {
|
||||
messages.push(message);
|
||||
}
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_input_matches(&messages[0], vec![escape_sequences::C0::ESC, b'i']);
|
||||
|
||||
assert_input_matches(&messages[1], expected_command_bytes("echo foo", &shell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_controller_cancels_async_writes_upon_user_command() {
|
||||
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
|
||||
let background_executor = Arc::new(Background::default());
|
||||
let mut controller = PtyController::new(
|
||||
event_loop_tx,
|
||||
background_executor.clone(),
|
||||
terminal_model(background_executor),
|
||||
);
|
||||
|
||||
controller.set_state_after_block_completed(
|
||||
&BlockType::User(UserBlockCompleted {
|
||||
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
|
||||
.into(),
|
||||
command: "echo foo".to_owned(),
|
||||
output_truncated: "".to_owned(),
|
||||
started_at: None,
|
||||
num_output_lines: 0,
|
||||
num_output_lines_truncated: 0,
|
||||
shell_type: None,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
// Writing this command should cancel writing the input buffer escape sequence, which is
|
||||
// written after a 50ms delay. Since only ~25 ms has passed the write should not have
|
||||
// occurred yet.
|
||||
assert!(controller.write_user_command("echo foo", &shell).is_ok());
|
||||
controller.flush_pty_writes();
|
||||
|
||||
let mut messages = vec![];
|
||||
while let Ok(message) = event_loop_rx.try_recv() {
|
||||
messages.push(message);
|
||||
}
|
||||
assert_eq!(messages.len(), 1);
|
||||
|
||||
assert_input_matches(&messages[0], expected_command_bytes("echo foo", &shell));
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
use instant::Instant;
|
||||
use settings::Setting;
|
||||
use warp_core::SessionId;
|
||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
|
||||
|
||||
use crate::terminal::warpify::settings::SshExtensionInstallMode;
|
||||
|
||||
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
|
||||
use crate::remote_server::ssh_transport::SshTransport;
|
||||
use crate::terminal::model::session::{IsLegacySSHSession, SessionInfo};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::warpify::settings::WarpifySettings;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
use remote_server::setup::RemotePlatform;
|
||||
|
||||
use super::pty_controller::{EventLoopSender, PtyController};
|
||||
|
||||
/// Per-SSH-init state machine. Encoding the state as an enum makes invalid
|
||||
/// transitions unrepresentable and ensures the `SessionInfo` stash cannot be
|
||||
/// accessed after it has been consumed.
|
||||
///
|
||||
/// Every active state carries `setup_start` so that the total setup duration
|
||||
/// can be measured when the flow reaches `SessionConnected`.
|
||||
enum SshInitState {
|
||||
Idle,
|
||||
/// Stash held, `check_binary` in flight.
|
||||
AwaitingCheck {
|
||||
session_info: SessionInfo,
|
||||
transport: SshTransport,
|
||||
setup_start: Instant,
|
||||
},
|
||||
/// Stash held, choice block showing.
|
||||
AwaitingUserChoice {
|
||||
session_info: SessionInfo,
|
||||
transport: SshTransport,
|
||||
setup_start: Instant,
|
||||
},
|
||||
/// Stash held, `install_binary` in flight.
|
||||
AwaitingInstall {
|
||||
session_id: SessionId,
|
||||
session_info: SessionInfo,
|
||||
transport: SshTransport,
|
||||
setup_start: Instant,
|
||||
},
|
||||
/// Stash held, `connect_session` in flight. Bootstrap is flushed only
|
||||
/// once `SessionConnected` arrives (or on connection failure).
|
||||
AwaitingConnect {
|
||||
session_id: SessionId,
|
||||
session_info: SessionInfo,
|
||||
setup_start: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
/// Per-pane orchestrator that defers the bootstrap script write for SSH sessions,
|
||||
/// checks for the remote-server binary, and presents a two-option choice block when the binary is missing.
|
||||
///
|
||||
/// Uses a [`WeakModelHandle`] back to [`PtyController`] to avoid preventing
|
||||
/// `PtyController` from being deallocated.
|
||||
pub struct RemoteServerController<T: EventLoopSender> {
|
||||
pty_controller: WeakModelHandle<PtyController<T>>,
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
state: SshInitState,
|
||||
/// Whether the binary was installed during this setup flow.
|
||||
did_install: bool,
|
||||
/// Detected remote platform from the binary check phase, used for telemetry.
|
||||
remote_platform: Option<RemotePlatform>,
|
||||
}
|
||||
|
||||
impl<T: EventLoopSender> Entity for RemoteServerController<T> {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl<T: EventLoopSender> RemoteServerController<T> {
|
||||
pub fn new(
|
||||
pty_controller: WeakModelHandle<PtyController<T>>,
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| {
|
||||
if let ModelEvent::SshInitShell {
|
||||
pending_session_info,
|
||||
} = event
|
||||
{
|
||||
me.on_ssh_init_shell_requested(pending_session_info.as_ref().clone(), ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let mgr = RemoteServerManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&mgr, |me, event, ctx| match event {
|
||||
RemoteServerManagerEvent::BinaryCheckComplete {
|
||||
session_id,
|
||||
result,
|
||||
remote_platform,
|
||||
} => {
|
||||
me.remote_platform = remote_platform.clone();
|
||||
me.on_binary_check_complete(*session_id, result.clone(), ctx);
|
||||
}
|
||||
RemoteServerManagerEvent::BinaryInstallComplete { session_id, result } => {
|
||||
me.on_binary_install_complete(*session_id, result.clone(), ctx);
|
||||
}
|
||||
RemoteServerManagerEvent::SessionConnected { session_id, .. } => {
|
||||
me.on_session_connected(*session_id, ctx);
|
||||
}
|
||||
RemoteServerManagerEvent::SessionConnectionFailed { session_id, .. } => {
|
||||
me.on_session_connection_failed(*session_id, ctx);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Self {
|
||||
pty_controller,
|
||||
model_event_dispatcher,
|
||||
state: SshInitState::Idle,
|
||||
did_install: false,
|
||||
remote_platform: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the `SessionInfo` from the stash and writes the bootstrap
|
||||
/// script to the PTY via `PtyController::initialize_shell`.
|
||||
fn flush_stashed_bootstrap(&mut self, session_info: SessionInfo, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(pty) = self.pty_controller.upgrade(ctx) {
|
||||
pty.update(ctx, |pty, ctx| {
|
||||
pty.initialize_shell(&session_info, ctx);
|
||||
});
|
||||
} else {
|
||||
log::warn!("PtyController dropped before bootstrap could be flushed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle -> AwaitingCheck
|
||||
fn on_ssh_init_shell_requested(&mut self, info: SessionInfo, ctx: &mut ModelContext<Self>) {
|
||||
let IsLegacySSHSession::Yes { socket_path } = &info.is_legacy_ssh_session else {
|
||||
return;
|
||||
};
|
||||
let session_id = info.session_id;
|
||||
let socket_path = socket_path.clone();
|
||||
|
||||
debug_assert!(matches!(self.state, SshInitState::Idle));
|
||||
match std::mem::replace(&mut self.state, SshInitState::Idle) {
|
||||
SshInitState::Idle => {}
|
||||
SshInitState::AwaitingCheck {
|
||||
session_info: old_info,
|
||||
..
|
||||
}
|
||||
| SshInitState::AwaitingUserChoice {
|
||||
session_info: old_info,
|
||||
..
|
||||
}
|
||||
| SshInitState::AwaitingInstall {
|
||||
session_info: old_info,
|
||||
..
|
||||
}
|
||||
| SshInitState::AwaitingConnect {
|
||||
session_info: old_info,
|
||||
..
|
||||
} => {
|
||||
self.flush_stashed_bootstrap(old_info, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
let transport = SshTransport::new(socket_path);
|
||||
self.did_install = false;
|
||||
self.remote_platform = None;
|
||||
self.state = SshInitState::AwaitingCheck {
|
||||
session_info: info,
|
||||
transport: transport.clone(),
|
||||
setup_start: Instant::now(),
|
||||
};
|
||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||
mgr.check_binary(session_id, transport, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_binary_check_complete(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
result: Result<bool, String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let SshInitState::AwaitingCheck {
|
||||
ref session_info, ..
|
||||
} = self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if session_info.session_id != session_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let SshInitState::AwaitingCheck {
|
||||
session_info,
|
||||
transport,
|
||||
setup_start,
|
||||
} = std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
unreachable!("just matched AwaitingCheck above");
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(true) => {
|
||||
self.state = SshInitState::AwaitingConnect {
|
||||
session_id,
|
||||
session_info,
|
||||
setup_start,
|
||||
};
|
||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||
mgr.connect_session(session_id, transport, ctx);
|
||||
});
|
||||
}
|
||||
Ok(false) => {
|
||||
let install_mode = *WarpifySettings::as_ref(ctx)
|
||||
.ssh_extension_install_mode
|
||||
.value();
|
||||
match install_mode {
|
||||
SshExtensionInstallMode::AlwaysAsk => {
|
||||
self.state = SshInitState::AwaitingUserChoice {
|
||||
session_info,
|
||||
transport,
|
||||
setup_start,
|
||||
};
|
||||
self.model_event_dispatcher.update(ctx, |d, ctx| {
|
||||
d.request_remote_server_block(session_id, ctx);
|
||||
});
|
||||
}
|
||||
SshExtensionInstallMode::AlwaysInstall => {
|
||||
self.did_install = true;
|
||||
self.state = SshInitState::AwaitingInstall {
|
||||
session_id,
|
||||
session_info,
|
||||
transport: transport.clone(),
|
||||
setup_start,
|
||||
};
|
||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||
mgr.install_binary(session_id, transport, ctx);
|
||||
});
|
||||
}
|
||||
SshExtensionInstallMode::NeverInstall => {
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Binary check failed for {session_id:?}: {err}");
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_ssh_remote_server_install(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let SshInitState::AwaitingUserChoice { .. } = self.state else {
|
||||
log::warn!("Install clicked but state is not AwaitingUserChoice for {session_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
let SshInitState::AwaitingUserChoice {
|
||||
session_info,
|
||||
transport,
|
||||
setup_start,
|
||||
} = std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
unreachable!("just matched AwaitingUserChoice above");
|
||||
};
|
||||
|
||||
self.did_install = true;
|
||||
self.state = SshInitState::AwaitingInstall {
|
||||
session_id,
|
||||
session_info,
|
||||
transport: transport.clone(),
|
||||
setup_start,
|
||||
};
|
||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||
mgr.install_binary(session_id, transport, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when the remote server session is connected. Flushes the
|
||||
/// stashed bootstrap (so the session initializes with a live client)
|
||||
/// and emits the `RemoteServerSetupDuration` telemetry event.
|
||||
fn on_session_connected(&mut self, session_id: SessionId, ctx: &mut ModelContext<Self>) {
|
||||
let SshInitState::AwaitingConnect {
|
||||
session_id: expected,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if *expected != session_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let SshInitState::AwaitingConnect {
|
||||
session_info,
|
||||
setup_start,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
unreachable!("just matched AwaitingConnect above");
|
||||
};
|
||||
|
||||
// Flush the stashed bootstrap now that the server is connected.
|
||||
// `client_for_session` will return `Some` when the session
|
||||
// subsequently initializes, so it picks `RemoteServerCommandExecutor`.
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
|
||||
let duration_ms = Instant::now()
|
||||
.duration_since(setup_start)
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
let (remote_os, remote_arch) = self
|
||||
.remote_platform
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
(
|
||||
Some(p.os.as_str().to_owned()),
|
||||
Some(p.arch.as_str().to_owned()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::RemoteServerSetupDuration {
|
||||
duration_ms,
|
||||
installed_binary: self.did_install,
|
||||
remote_os,
|
||||
remote_arch,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when the remote server connection failed. Flushes the stashed
|
||||
/// bootstrap so the SSH session is not permanently blocked.
|
||||
fn on_session_connection_failed(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let SshInitState::AwaitingConnect {
|
||||
session_id: expected,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if *expected != session_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let SshInitState::AwaitingConnect { session_info, .. } =
|
||||
std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
unreachable!("just matched AwaitingConnect above");
|
||||
};
|
||||
log::warn!(
|
||||
"Remote server connection failed for session {session_id:?}; \
|
||||
flushing bootstrap to unblock SSH session"
|
||||
);
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
}
|
||||
|
||||
pub fn handle_ssh_remote_server_skip(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let SshInitState::AwaitingUserChoice { session_info, .. } =
|
||||
std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
log::warn!("Skip clicked but state is not AwaitingUserChoice for {session_id:?}");
|
||||
return;
|
||||
};
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
}
|
||||
|
||||
fn on_binary_install_complete(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
result: Result<(), String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let SshInitState::AwaitingInstall {
|
||||
session_id: expected,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if *expected != session_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let SshInitState::AwaitingInstall {
|
||||
session_info,
|
||||
transport,
|
||||
setup_start,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, SshInitState::Idle)
|
||||
else {
|
||||
unreachable!("just matched AwaitingInstall above");
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.state = SshInitState::AwaitingConnect {
|
||||
session_id,
|
||||
session_info,
|
||||
setup_start,
|
||||
};
|
||||
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
|
||||
mgr.connect_session(session_id, transport, ctx);
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Binary install failed for {session_id:?}: {err}");
|
||||
self.flush_stashed_bootstrap(session_info, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::terminal::line_editor_status::LineEditorStatus;
|
||||
use crate::terminal::model::session::{ExecutorCommandEvent, Sessions};
|
||||
|
||||
use crate::terminal::model::terminal_model::ExitReason;
|
||||
use crate::terminal::view;
|
||||
use crate::terminal::writeable_pty::command_history::update_command_history;
|
||||
use crate::terminal::writeable_pty::pty_controller::EventLoopSender;
|
||||
use crate::terminal::writeable_pty::{PtyController, PtyControllerEvent};
|
||||
use crate::terminal::ModelEventDispatcher;
|
||||
use crate::terminal::{TerminalModel, TerminalView};
|
||||
|
||||
use async_channel::Receiver;
|
||||
use parking_lot::FairMutex;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, ModelHandle, ViewHandle};
|
||||
|
||||
/// Wires up bi-directional communication between the PtyController and the TerminalView.
|
||||
/// Note that this interaction can't live in the TerminalView because the view must be manager-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 should use weak handles and upgrade them lazily.
|
||||
pub fn wire_up_pty_controller_with_view<T: EventLoopSender>(
|
||||
pty_controller: &ModelHandle<PtyController<T>>,
|
||||
terminal_view: &ViewHandle<TerminalView>,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model_event_sender: Option<SyncSender<ModelEvent>>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let controller_weak_handle = pty_controller.downgrade();
|
||||
let view_weak_handle = terminal_view.downgrade();
|
||||
let model_clone = model.clone();
|
||||
|
||||
ctx.subscribe_to_view(terminal_view, move |_view, event, ctx| {
|
||||
// NOTE: we cannot simply use the strong reference (the model handle argument to this wire_up fn)
|
||||
// because it'll create a reference cycle with the `subscribe_to_model` usage below. Instead,
|
||||
// we should use a weak handle and upgrade it lazily.
|
||||
let Some(controller) = controller_weak_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match event {
|
||||
view::Event::CtrlD => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.write_end_of_transmission_char(ctx);
|
||||
});
|
||||
}
|
||||
view::Event::ShutdownPty => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.shutdown_pty(ctx);
|
||||
});
|
||||
}
|
||||
view::Event::WriteBytesToPty { bytes } => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
// TODO: the underlying bytes should be wrapped in an Arc and copied out only when they need to be written to the PTY.
|
||||
controller.write_bytes(bytes.clone(), ctx);
|
||||
});
|
||||
}
|
||||
view::Event::WriteAgentInputToPty { bytes, mode } => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.write_agent_bytes(bytes.clone(), mode, ctx);
|
||||
});
|
||||
}
|
||||
view::Event::Resize { size_update } => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.resize_pty(*size_update, ctx);
|
||||
});
|
||||
}
|
||||
view::Event::ExecuteCommand(event) => {
|
||||
let Some(shell_type) = sessions
|
||||
.as_ref(ctx)
|
||||
.get(event.session_id)
|
||||
.map(|s| s.shell().shell_type())
|
||||
else {
|
||||
log::warn!("Failed to get shell type for the session associated to the command execution event.");
|
||||
return;
|
||||
};
|
||||
|
||||
model_clone.lock().block_list_mut().active_block_mut().set_cloud_workflow_state(event.workflow_id);
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.write_command(&event.command, shell_type, event.source.clone(), ctx)
|
||||
});
|
||||
|
||||
if event.should_add_command_to_history {
|
||||
update_command_history(
|
||||
event,
|
||||
&model_clone,
|
||||
model_event_sender.as_ref(),
|
||||
&sessions,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
view::Event::RunNativeShellCompletions { buffer_text, results_tx } => {
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.run_native_shell_completions(buffer_text.clone(), results_tx.clone(), ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(pty_controller, move |_pty_controller, event, ctx| {
|
||||
let Some(view) = view_weak_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match event {
|
||||
PtyControllerEvent::PtyDisconnected => view.update(ctx, |_, _| {
|
||||
model.lock().exit(ExitReason::PtyDisconnected);
|
||||
}),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wires up bi-directional communication between the RemoteServerController and the TerminalView.
|
||||
/// This handles the flow to render the remote server block and forward the user's choice to the controller.
|
||||
///
|
||||
/// Note that this interaction can't live in the TerminalView because the view must be manager-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 should use weak handles and upgrade them lazily.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn wire_up_remote_server_controller_with_view<T: EventLoopSender>(
|
||||
remote_server_controller: &ModelHandle<
|
||||
super::remote_server_controller::RemoteServerController<T>,
|
||||
>,
|
||||
terminal_view: &ViewHandle<TerminalView>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let controller_weak = remote_server_controller.downgrade();
|
||||
ctx.subscribe_to_view(terminal_view, move |_view, event, ctx| {
|
||||
let Some(controller) = controller_weak.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
view::Event::RemoteServerInstallRequested { session_id } => {
|
||||
controller.update(ctx, |ctrl, ctx| {
|
||||
ctrl.handle_ssh_remote_server_install(*session_id, ctx);
|
||||
});
|
||||
}
|
||||
view::Event::RemoteServerSkipRequested { session_id } => {
|
||||
controller.update(ctx, |ctrl, ctx| {
|
||||
ctrl.handle_ssh_remote_server_skip(*session_id, ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a PtyController to broker writes to the PTY and registers it as a model.
|
||||
pub fn init_pty_controller_model<Sender: EventLoopSender>(
|
||||
event_loop_tx: Sender,
|
||||
executor_command_rx: Receiver<ExecutorCommandEvent>,
|
||||
model_events: ModelHandle<ModelEventDispatcher>,
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut AppContext,
|
||||
) -> ModelHandle<PtyController<Sender>> {
|
||||
let line_editor_status =
|
||||
ctx.add_model(|ctx| LineEditorStatus::new(model_events.clone(), sessions.clone(), ctx));
|
||||
|
||||
ctx.add_model(|ctx| {
|
||||
PtyController::new(
|
||||
event_loop_tx,
|
||||
model_events,
|
||||
line_editor_status,
|
||||
sessions,
|
||||
executor_command_rx,
|
||||
model,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a [`RemoteServerController`] that orchestrates the SSH init flow.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn init_remote_server_controller<Sender: EventLoopSender>(
|
||||
pty_controller: &ModelHandle<PtyController<Sender>>,
|
||||
model_events: &ModelHandle<ModelEventDispatcher>,
|
||||
ctx: &mut AppContext,
|
||||
) -> ModelHandle<super::remote_server_controller::RemoteServerController<Sender>> {
|
||||
let pty_weak = pty_controller.downgrade();
|
||||
ctx.add_model(|ctx| {
|
||||
super::remote_server_controller::RemoteServerController::new(
|
||||
pty_weak,
|
||||
model_events.clone(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user