Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use prost::Message;
|
||||
use warp_multi_agent_api::ResponseEvent;
|
||||
|
||||
/// Decodes a serialized response event string by base64-decoding
|
||||
/// and then decoding the protobuf payload into a ResponseEvent.
|
||||
pub fn decode_agent_response_event(encoded: &str) -> Result<ResponseEvent> {
|
||||
let bytes = STANDARD_NO_PAD.decode(encoded)?;
|
||||
let event = ResponseEvent::decode(bytes.as_slice())?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Encodes a ResponseEvent by protobuf-encoding it and base64-encoding the bytes.
|
||||
pub fn encode_agent_response_event(event: &ResponseEvent) -> String {
|
||||
let bytes = event.encode_to_vec();
|
||||
STANDARD_NO_PAD.encode(bytes)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
|
||||
use warpui::{
|
||||
AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WeakViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
use super::SharedSessionActionSource;
|
||||
|
||||
struct SharedSessionState {
|
||||
session_id: SessionId,
|
||||
view_handle: WeakViewHandle<TerminalView>,
|
||||
}
|
||||
|
||||
/// A global model that tracks shared session metadata for sessions across all windows.
|
||||
pub struct Manager {
|
||||
/// Sessions that were shared by this client.
|
||||
shared: HashMap<EntityId, SharedSessionState>,
|
||||
|
||||
/// Sessions that were joined by this client.
|
||||
joined: HashMap<EntityId, SharedSessionState>,
|
||||
|
||||
/// IDs of sessions that were shared or joined by this client,
|
||||
/// but have since been stopped. This state is maintained so that the
|
||||
/// copy link button can still work for ended sessions.
|
||||
ended_session_ids: HashMap<EntityId, SessionId>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
shared: Default::default(),
|
||||
joined: Default::default(),
|
||||
ended_session_ids: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff there are >= 1 active shares.
|
||||
pub fn is_some_session_being_shared(&self) -> bool {
|
||||
!self.shared.is_empty()
|
||||
}
|
||||
|
||||
/// Returns true iff there are >= 1 active joined sessions.
|
||||
pub fn is_some_session_being_viewed(&self) -> bool {
|
||||
!self.joined.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the session id for the given terminal view.
|
||||
pub fn session_id(&self, terminal_view_id: &EntityId) -> Option<SessionId> {
|
||||
self.shared
|
||||
.get(terminal_view_id)
|
||||
.or(self.joined.get(terminal_view_id))
|
||||
.map(|state| state.session_id)
|
||||
}
|
||||
|
||||
/// Returns the most recently ended session id for the given terminal view.
|
||||
pub fn ended_session_id(&self, terminal_view_id: &EntityId) -> Option<SessionId> {
|
||||
self.ended_session_ids.get(terminal_view_id).copied()
|
||||
}
|
||||
|
||||
/// Returns the view handle to the shared terminal view, identified by `terminal_view_id`, if it's being shared.
|
||||
pub fn shared_view_by_id(
|
||||
&self,
|
||||
terminal_view_id: &EntityId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
let weak_handle = self
|
||||
.shared
|
||||
.get(terminal_view_id)
|
||||
.map(|state| state.view_handle.clone())?;
|
||||
|
||||
let view_handle = weak_handle.upgrade(ctx);
|
||||
if view_handle.is_none() {
|
||||
log::warn!("Failed to upgrade a terminal view in the shared session manager");
|
||||
}
|
||||
|
||||
view_handle
|
||||
}
|
||||
|
||||
/// Returns the view handle to the joined terminal view, identified by `terminal_view_id`, if it's being viewed.
|
||||
pub fn joined_view_by_id(
|
||||
&self,
|
||||
terminal_view_id: &EntityId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
let weak_handle = self
|
||||
.joined
|
||||
.get(terminal_view_id)
|
||||
.map(|state| state.view_handle.clone())?;
|
||||
|
||||
let view_handle = weak_handle.upgrade(ctx);
|
||||
if view_handle.is_none() {
|
||||
log::warn!("Failed to upgrade a terminal view in the joined session manager");
|
||||
}
|
||||
|
||||
view_handle
|
||||
}
|
||||
|
||||
pub fn shared_view_ids(&self) -> impl Iterator<Item = EntityId> + '_ {
|
||||
self.shared.keys().cloned()
|
||||
}
|
||||
|
||||
pub fn joined_view_ids(&self) -> impl Iterator<Item = EntityId> + '_ {
|
||||
self.joined.keys().cloned()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the set of all shared sessions.
|
||||
pub fn shared_views<'a>(
|
||||
&'a self,
|
||||
ctx: &'a AppContext,
|
||||
) -> impl Iterator<Item = ViewHandle<TerminalView>> + 'a {
|
||||
self.shared
|
||||
.values()
|
||||
.filter_map(move |state| state.view_handle.upgrade(ctx))
|
||||
}
|
||||
|
||||
pub fn started_share(
|
||||
&mut self,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
session_id: SessionId,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let view_id = terminal_view.id();
|
||||
let state = SharedSessionState {
|
||||
session_id,
|
||||
view_handle: terminal_view,
|
||||
};
|
||||
self.shared.insert(view_id, state);
|
||||
ctx.emit(ManagerEvent::StartedShare {
|
||||
session_id,
|
||||
window_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn joined_share(
|
||||
&mut self,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
session_id: SessionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let view_id = terminal_view.id();
|
||||
let state = SharedSessionState {
|
||||
session_id,
|
||||
view_handle: terminal_view,
|
||||
};
|
||||
self.joined.insert(view_id, state);
|
||||
ctx.emit(ManagerEvent::JoinedSession {
|
||||
session_id,
|
||||
view_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn left_share(&mut self, terminal_view_id: EntityId) {
|
||||
// Remove the shared session from the shared sessions map and persist the session id.
|
||||
if let Some(removed_session) = self.joined.remove(&terminal_view_id) {
|
||||
self.ended_session_ids
|
||||
.insert(terminal_view_id, removed_session.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped_share(&mut self, terminal_view_id: EntityId, ctx: &mut ModelContext<Self>) {
|
||||
// Remove the shared session from the shared sessions map and persist the session id.
|
||||
if let Some(removed_session) = self.shared.remove(&terminal_view_id) {
|
||||
self.ended_session_ids
|
||||
.insert(terminal_view_id, removed_session.session_id);
|
||||
}
|
||||
|
||||
ctx.emit(ManagerEvent::StoppedShare);
|
||||
}
|
||||
|
||||
pub fn share_failed(&mut self, window_id: WindowId, ctx: &mut ModelContext<Self>) {
|
||||
ctx.emit(ManagerEvent::FailedToShare { window_id });
|
||||
}
|
||||
|
||||
pub fn clear_joined(&mut self) {
|
||||
self.joined.clear();
|
||||
}
|
||||
|
||||
pub fn stop_all_shared_sessions(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let view_ids = self.shared_view_ids().collect_vec();
|
||||
|
||||
for view_id in view_ids {
|
||||
if let Some(terminal_view) = self.shared_view_by_id(&view_id, ctx) {
|
||||
terminal_view.update(ctx, |view, ctx| {
|
||||
view.stop_sharing_session(SharedSessionActionSource::NonUser, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejoin_all_shared_sessions(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.is_some_session_being_viewed() {
|
||||
let view_ids = self.joined_view_ids().collect_vec();
|
||||
|
||||
for view_id in view_ids {
|
||||
if let Some(terminal_view) = self.joined_view_by_id(&view_id, ctx) {
|
||||
terminal_view.update(ctx, |view, ctx| {
|
||||
view.rejoin_session_share(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ManagerEvent {
|
||||
/// There was an attempt to share a session.
|
||||
ShareAttempted,
|
||||
/// A shared session was started.
|
||||
StartedShare {
|
||||
session_id: SessionId,
|
||||
/// The window that the session resides in.
|
||||
window_id: WindowId,
|
||||
},
|
||||
/// A shared session has been successfully joined.
|
||||
JoinedSession {
|
||||
/// the session_id of the session that was joined.
|
||||
session_id: SessionId,
|
||||
/// The view_id of the terminal that joined the session.
|
||||
view_id: EntityId,
|
||||
},
|
||||
/// A shared session was stopped.
|
||||
StoppedShare,
|
||||
/// There was an attempt to share a session but it failed.
|
||||
FailedToShare {
|
||||
/// The window that the session resides in.
|
||||
window_id: WindowId,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for Manager {
|
||||
type Event = ManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for Manager {}
|
||||
@@ -0,0 +1,422 @@
|
||||
use byte_unit::Byte;
|
||||
use instant::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_sharing_protocol::common::{Role, Scrollback, ScrollbackBlock, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionSourceType;
|
||||
use warpui::{id, keymap::ContextPredicate, AppContext};
|
||||
|
||||
use crate::{
|
||||
channel::{Channel, ChannelState},
|
||||
editor::{InteractionState, ReplicaId},
|
||||
features::FeatureFlag,
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::{block::SerializedBlock, terminal_model::BlockIndex},
|
||||
GridType, TerminalModel,
|
||||
};
|
||||
|
||||
pub mod ai_agent;
|
||||
pub mod manager;
|
||||
pub mod network;
|
||||
pub mod participant_avatar_view;
|
||||
pub mod permissions_manager;
|
||||
pub mod presence_manager;
|
||||
pub mod render_util;
|
||||
pub mod replay_agent_conversations;
|
||||
pub mod role_change_modal;
|
||||
mod selections;
|
||||
pub mod settings;
|
||||
pub mod share_modal;
|
||||
pub(super) mod shared_handlers;
|
||||
pub mod sharer;
|
||||
pub mod viewer;
|
||||
|
||||
#[cfg(test)]
|
||||
pub use tests::MAX_BYTES_SHAREABLE;
|
||||
|
||||
/// The toast copy when copying a shared session link.
|
||||
pub const COPY_LINK_TEXT: &str = "Sharing link copied";
|
||||
|
||||
/// Throttle period for selection updates. We throttle instead of debounce because we want
|
||||
/// to send selections even when it updates fast, so it appears live.
|
||||
/// Our throttle implementation throttles on the trailing edge (does not drop messages at the end, so the
|
||||
/// most up to date will always be sent after some delay)
|
||||
const SELECTION_THROTTLE_PERIOD: Duration = Duration::from_millis(20);
|
||||
|
||||
/// Whether or not a local session is also being shared.
|
||||
/// Since a shared session creator is also the creator of a local session,
|
||||
/// we make use of the local_tty::TerminalManager for shared session creators.
|
||||
/// Otherwise, there would be a lot of overlap between a shared session creator
|
||||
/// and a regular, purely local session.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum IsSharedSessionCreator {
|
||||
/// This session should be shared automatically once bootstrapped, using the
|
||||
/// provided source type.
|
||||
Yes { source_type: SessionSourceType },
|
||||
#[default]
|
||||
No,
|
||||
}
|
||||
|
||||
/// The type of shared session a particular session is, if applicable.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SharedSessionStatus {
|
||||
/// This session is not a shared session.
|
||||
/// When a sharer ends a session, the status
|
||||
/// changes back to [`SharedSessionStatus::NotShared`].
|
||||
NotShared,
|
||||
|
||||
/// We're in the process of joining the session but have not
|
||||
/// established the connection with the server yet, or have not received all the events that occurred before the viewer joined yet.
|
||||
ViewPending,
|
||||
|
||||
/// This session is a shared session that we are actively viewing.
|
||||
/// We have received all the scrollback and events for the shared session that occurred before the viewer joined, and are caught up and receiving events live.
|
||||
ActiveViewer { role: Role },
|
||||
|
||||
/// We were viewing a shared session but it ended.
|
||||
FinishedViewer,
|
||||
|
||||
/// We haven't yet attempted to share the session because it is not bootstrapped yet.
|
||||
/// The `source_type` encodes what kind of shared session will be created once the
|
||||
/// session finishes bootstrapping.
|
||||
SharePendingPreBootstrap { source_type: SessionSourceType },
|
||||
|
||||
/// The session is bootstrapped and we're in the process of
|
||||
/// sharing the session but have not yet established the
|
||||
/// connection with the server.
|
||||
SharePending,
|
||||
|
||||
/// This session is actively being shared.
|
||||
ActiveSharer,
|
||||
}
|
||||
|
||||
impl SharedSessionStatus {
|
||||
pub fn reader() -> Self {
|
||||
Self::ActiveViewer { role: Role::Reader }
|
||||
}
|
||||
|
||||
pub fn executor() -> Self {
|
||||
Self::ActiveViewer {
|
||||
role: Role::Executor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_view_pending(&self) -> bool {
|
||||
matches!(self, SharedSessionStatus::ViewPending)
|
||||
}
|
||||
|
||||
pub fn is_active_viewer(&self) -> bool {
|
||||
matches!(self, SharedSessionStatus::ActiveViewer { .. })
|
||||
}
|
||||
|
||||
pub fn is_finished_viewer(&self) -> bool {
|
||||
matches!(self, SharedSessionStatus::FinishedViewer)
|
||||
}
|
||||
|
||||
pub fn is_viewer(&self) -> bool {
|
||||
self.is_view_pending() || self.is_active_viewer() || self.is_finished_viewer()
|
||||
}
|
||||
|
||||
pub fn is_executor(&self) -> bool {
|
||||
matches!(self, SharedSessionStatus::ActiveViewer { role } if role.can_execute())
|
||||
}
|
||||
|
||||
pub fn is_reader(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SharedSessionStatus::ActiveViewer { role: Role::Reader }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_share_pending(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SharedSessionStatus::SharePending
|
||||
| SharedSessionStatus::SharePendingPreBootstrap { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_active_sharer(&self) -> bool {
|
||||
matches!(self, SharedSessionStatus::ActiveSharer)
|
||||
}
|
||||
|
||||
pub fn is_sharer(&self) -> bool {
|
||||
self.is_share_pending() || self.is_active_sharer()
|
||||
}
|
||||
|
||||
pub fn is_sharer_or_viewer(&self) -> bool {
|
||||
!matches!(self, Self::NotShared)
|
||||
}
|
||||
|
||||
pub fn as_keymap_context(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotShared => "SharedSessionStatus_NotShared",
|
||||
Self::ViewPending => "SharedSessionStatus_ViewPending",
|
||||
Self::ActiveViewer { role: Role::Reader } => "SharedSessionStatus_Reader",
|
||||
Self::ActiveViewer {
|
||||
role: Role::Executor | Role::Full,
|
||||
} => "SharedSessionStatus_Executor",
|
||||
Self::FinishedViewer => "SharedSessionStatus_FinishedViewer",
|
||||
Self::SharePendingPreBootstrap { .. } => "SharedSessionStatus_SharePendingPreBootstrap",
|
||||
Self::SharePending => "SharedSessionStatus_SharePending",
|
||||
Self::ActiveSharer => "SharedSessionStatus_ActiveSharer",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_viewer_keymap_context() -> ContextPredicate {
|
||||
id!(Self::reader().as_keymap_context()) | id!(Self::executor().as_keymap_context())
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrollback options when starting a shared session.
|
||||
/// Note: currently, these options only encode the point at which
|
||||
/// scrollback _starts_. We do not yet support more
|
||||
/// selective scrollback (e.g. a closed range).
|
||||
/// The active block is always included in scrollback for the prompt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SharedSessionScrollbackType {
|
||||
/// Do not include any scrollback in this shared session.
|
||||
/// Note the active block is still sent as part of scrollback for the prompt.
|
||||
/// TODO(suraj): consider renaming this to "from active block" or encapsulating
|
||||
/// this with the `FromBlock` variant with the block_index equal to the
|
||||
/// active block index.
|
||||
None,
|
||||
|
||||
/// Include scrollback starting at `block_index`.
|
||||
FromBlock { block_index: BlockIndex },
|
||||
|
||||
/// The entire blocklist should be part of the scrollback.
|
||||
All,
|
||||
}
|
||||
|
||||
impl SharedSessionScrollbackType {
|
||||
/// Returns the set of scrollback that adheres to the scrollback type.
|
||||
/// Note that some blocks might not actually be included in the scrollback
|
||||
/// even if they were specified as part of the scrollback type.
|
||||
/// For example, if the [`Self::All]` variant is used, restored blocks
|
||||
/// _won't_ be included in scrollback.
|
||||
fn to_scrollback(self, model: &TerminalModel) -> Scrollback {
|
||||
let first_block_index = self.first_block_index(model);
|
||||
let blocks = model
|
||||
.block_list()
|
||||
.blocks()
|
||||
.iter()
|
||||
.skip(first_block_index.into())
|
||||
.filter(|block| {
|
||||
block.is_scrollback_block_for_shared_session(model.block_list().agent_view_state())
|
||||
})
|
||||
.filter_map(|block| {
|
||||
let serialized_block: SerializedBlock = block.into();
|
||||
let bytes = serde_json::to_vec(&serialized_block);
|
||||
bytes.ok().map(|raw| ScrollbackBlock { raw })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let is_alt_screen_active = model.is_alt_screen_active();
|
||||
|
||||
Scrollback {
|
||||
blocks,
|
||||
is_alt_screen_active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first block index that will be used for scrollback.
|
||||
pub fn first_block_index(self, model: &TerminalModel) -> BlockIndex {
|
||||
match self {
|
||||
Self::None => model.block_list().active_block_index(),
|
||||
Self::FromBlock { block_index } => model
|
||||
.block_list()
|
||||
.blocks()
|
||||
.iter()
|
||||
.skip(block_index.into())
|
||||
.find(|block| {
|
||||
block.is_scrollback_block_for_shared_session(
|
||||
model.block_list().agent_view_state(),
|
||||
)
|
||||
})
|
||||
.map_or(model.block_list().active_block_index(), |block| {
|
||||
block.index()
|
||||
}),
|
||||
Self::All => Self::FromBlock {
|
||||
block_index: BlockIndex::zero(),
|
||||
}
|
||||
.first_block_index(model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn max_session_size(ctx: &AppContext) -> Byte {
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
UserWorkspaces::as_ref(ctx)
|
||||
.current_team()
|
||||
.and_then(|team| team.billing_metadata.tier.session_sharing_policy)
|
||||
.map(|policy| Byte::from_u64(policy.max_session_size))
|
||||
.unwrap_or(Byte::from_u64_with_unit(100, byte_unit::Unit::MB).unwrap())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn max_session_size(_ctx: &AppContext) -> Byte {
|
||||
Byte::from_u64(MAX_BYTES_SHAREABLE as u64)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub enum SharedSessionActionSource {
|
||||
/// From right-click menu in blocklist
|
||||
/// * `block_index`: provided with selected block, none when no blocks selected
|
||||
BlocklistContextMenu {
|
||||
block_index: Option<BlockIndex>,
|
||||
},
|
||||
Tab,
|
||||
PaneHeader,
|
||||
/// Includes keybindings.
|
||||
CommandPalette,
|
||||
OnboardingBlock,
|
||||
Closed {
|
||||
is_confirm_close_session: bool,
|
||||
},
|
||||
InactivityModal,
|
||||
/// The user did not initiate this action themselves.
|
||||
NonUser,
|
||||
/// The object-specific sharing dialog.
|
||||
SharingDialog,
|
||||
/// From the session sharing context menu items.
|
||||
RightClickMenu,
|
||||
/// From the agent/CLI footer chip.
|
||||
FooterChip,
|
||||
}
|
||||
|
||||
/// Returns the native intent URL to join a shared session.
|
||||
/// This should be used when opening the session from within Warp.
|
||||
pub fn join_native_intent(session_id: &SessionId) -> String {
|
||||
format!(
|
||||
"{}://shared_session/{}",
|
||||
ChannelState::url_scheme(),
|
||||
session_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the link to join a shared session.
|
||||
pub fn join_link(session_id: &SessionId) -> String {
|
||||
// For non-bundled builds against the staging server, use the native app intent
|
||||
// because the staging web URL won't resolve to a local build.
|
||||
let use_web_url = !ChannelState::uses_staging_server() || cfg!(feature = "release_bundle");
|
||||
|
||||
let mut link = if use_web_url {
|
||||
format!("{}/session/{}", ChannelState::server_root_url(), session_id,)
|
||||
} else {
|
||||
join_native_intent(session_id)
|
||||
};
|
||||
|
||||
// If this is a preview build, route the sharing link to the preview server.
|
||||
if matches!(ChannelState::channel(), Channel::Preview) {
|
||||
link.push_str("?preview=true");
|
||||
}
|
||||
|
||||
link
|
||||
}
|
||||
|
||||
/// Returns the full session sharing URL given a path.
|
||||
pub fn connect_endpoint(path: String) -> Option<String> {
|
||||
let base = ChannelState::session_sharing_server_url()?;
|
||||
if FeatureFlag::SessionSharingAcls.is_enabled() {
|
||||
let version = ChannelState::app_version().unwrap_or("v0.00.000");
|
||||
if path.contains("?") {
|
||||
return Some(format!("{base}{path}&version={version}"));
|
||||
} else {
|
||||
return Some(format!("{base}{path}?version={version}"));
|
||||
}
|
||||
}
|
||||
Some(format!("{base}{path}"))
|
||||
}
|
||||
|
||||
/// The event number for events sent to the server. The newtype
|
||||
/// ensures that events are incremented correctly.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
struct EventNumber(usize);
|
||||
|
||||
impl EventNumber {
|
||||
fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
/// Returns the current event number and increments
|
||||
/// it for the next usage. The event number returned
|
||||
/// is the event number that should be used for the next
|
||||
/// event to send to the server.
|
||||
pub fn advance(&mut self) -> usize {
|
||||
let next = self.0;
|
||||
self.0 += 1;
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EventNumber> for usize {
|
||||
fn from(value: EventNumber) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GridType> for session_sharing_protocol::common::GridType {
|
||||
fn from(val: GridType) -> Self {
|
||||
match val {
|
||||
GridType::Prompt => session_sharing_protocol::common::GridType::Prompt,
|
||||
GridType::Rprompt => session_sharing_protocol::common::GridType::Rprompt,
|
||||
GridType::Output => session_sharing_protocol::common::GridType::Output,
|
||||
GridType::PromptAndCommand => {
|
||||
session_sharing_protocol::common::GridType::PromptAndCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_sharing_protocol::common::GridType> for GridType {
|
||||
fn from(value: session_sharing_protocol::common::GridType) -> Self {
|
||||
match value {
|
||||
session_sharing_protocol::common::GridType::Prompt => Self::Prompt,
|
||||
session_sharing_protocol::common::GridType::Rprompt => Self::Rprompt,
|
||||
session_sharing_protocol::common::GridType::Output => Self::Output,
|
||||
session_sharing_protocol::common::GridType::PromptAndCommand => Self::PromptAndCommand,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReplicaId> for session_sharing_protocol::common::InputReplicaId {
|
||||
fn from(value: ReplicaId) -> Self {
|
||||
value.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_sharing_protocol::common::InputReplicaId> for ReplicaId {
|
||||
fn from(value: session_sharing_protocol::common::InputReplicaId) -> Self {
|
||||
ReplicaId::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Role> for InteractionState {
|
||||
fn from(value: &Role) -> InteractionState {
|
||||
match value {
|
||||
Role::Reader => InteractionState::Selectable,
|
||||
Role::Executor => InteractionState::Editable,
|
||||
Role::Full => InteractionState::Editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode scrollback blocks from their JSON wire format into [`SerializedBlock`]s.
|
||||
///
|
||||
/// Blocks that fail to deserialize are silently dropped.
|
||||
pub(crate) fn decode_scrollback(scrollback: &Scrollback) -> Vec<SerializedBlock> {
|
||||
scrollback
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| serde_json::from_slice(&block.raw).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,348 @@
|
||||
use super::{decode_scrollback, SharedSessionScrollbackType};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewState;
|
||||
use crate::assert_lines_approx_eq;
|
||||
use crate::channel::ChannelState;
|
||||
use crate::terminal::color::List;
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::uri::web_intent_parser::maybe_rewrite_web_url_to_intent;
|
||||
|
||||
use crate::terminal::model::ObfuscateSecrets;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::terminal::{event_listener::ChannelEventListener, model::block::SerializedBlock};
|
||||
use crate::themes::default_themes::dark_theme;
|
||||
use serde_json::Value;
|
||||
use session_sharing_protocol::common::{Scrollback, ScrollbackBlock};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::units::Lines;
|
||||
|
||||
pub const MAX_BYTES_SHAREABLE: usize = 5000;
|
||||
|
||||
#[test]
|
||||
fn maybe_rewrite_web_url_to_shared_session_intent_rewrites_matching_web_url() {
|
||||
let server_root = ChannelState::server_root_url();
|
||||
let web_url = Url::parse(&format!(
|
||||
"{server_root}/session/00000000-0000-0000-0000-000000000000?pwd=secret&preview=true"
|
||||
))
|
||||
.expect("valid shared session web URL");
|
||||
|
||||
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url)
|
||||
.expect("expected shared session web URL to rewrite to an intent URL");
|
||||
|
||||
assert_eq!(maybe_intent.scheme(), ChannelState::url_scheme());
|
||||
assert_eq!(maybe_intent.host_str(), Some("shared_session"));
|
||||
assert_eq!(maybe_intent.path(), "/00000000-0000-0000-0000-000000000000");
|
||||
assert_eq!(maybe_intent.query(), Some("pwd=secret&preview=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_non_matching_host() {
|
||||
let web_url =
|
||||
Url::parse("https://example.com/session/00000000-0000-0000-0000-000000000000?pwd=secret")
|
||||
.expect("valid web URL with non-matching host");
|
||||
|
||||
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
|
||||
|
||||
assert!(maybe_intent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_invalid_session_id() {
|
||||
let server_root = ChannelState::server_root_url();
|
||||
let web_url = Url::parse(&format!(
|
||||
"{server_root}/session/not-a-valid-session-id?pwd=secret&preview=true",
|
||||
))
|
||||
.expect("valid web URL with invalid session id path segment");
|
||||
|
||||
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
|
||||
|
||||
assert!(maybe_intent.is_none());
|
||||
}
|
||||
|
||||
pub fn terminal_model_for_viewer(event_proxy: ChannelEventListener) -> TerminalModel {
|
||||
TerminalModel::new_for_shared_session_viewer(
|
||||
block_size(),
|
||||
List::from(&dark_theme().into()),
|
||||
event_proxy,
|
||||
Arc::new(Background::default()),
|
||||
false, /* show_memory_stats */
|
||||
false, /* honor_ps1 */
|
||||
false, /* is_inverted */
|
||||
ObfuscateSecrets::No,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_no_scrollback() {
|
||||
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
||||
|
||||
model.simulate_block("block1", "block1");
|
||||
model.simulate_block("block2", "block2");
|
||||
|
||||
let scrollback = SharedSessionScrollbackType::None.to_scrollback(&model);
|
||||
// Should only contain the active block
|
||||
assert_eq!(scrollback.blocks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_scrollback_starting_at_block() {
|
||||
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
||||
|
||||
model.simulate_block("block1", "block1");
|
||||
model.simulate_block("block2", "block2");
|
||||
|
||||
let starting_block = model
|
||||
.block_list()
|
||||
.last_non_hidden_block()
|
||||
.expect("there is a non-hidden block");
|
||||
let scrollback = SharedSessionScrollbackType::FromBlock {
|
||||
block_index: starting_block.index(),
|
||||
}
|
||||
.to_scrollback(&model);
|
||||
|
||||
// Should contain 1 completed block + active block
|
||||
assert_eq!(scrollback.blocks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_scrollback() {
|
||||
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
||||
|
||||
// Restored blocks and bootstrap blocks don't count towards scrollback,
|
||||
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
// Only active block
|
||||
assert_eq!(scrollback.blocks.len(), 1);
|
||||
|
||||
model.simulate_block("block1", "block1");
|
||||
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
assert_eq!(scrollback.blocks.len(), 2);
|
||||
|
||||
model.simulate_block("block2", "block2");
|
||||
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
|
||||
// Should contain 2 completed blocks + active block
|
||||
assert_eq!(scrollback.blocks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrollback_round_trip() {
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
|
||||
|
||||
model.simulate_block("hello", "world");
|
||||
|
||||
// Capture the expected stylized bytes from the completed block before serialization.
|
||||
let completed_block = model.block_list().block_at(1.into()).unwrap();
|
||||
let expected: SerializedBlock = completed_block.into();
|
||||
|
||||
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
let decoded = decode_scrollback(&scrollback);
|
||||
|
||||
// The completed block is first; the active (empty) block is second.
|
||||
assert_eq!(decoded.len(), 2);
|
||||
assert_eq!(decoded[0].stylized_command, expected.stylized_command);
|
||||
assert_eq!(decoded[0].stylized_output, expected.stylized_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrollback_serialization() {
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
|
||||
|
||||
model.simulate_block("hello", "world");
|
||||
|
||||
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
let first_block = scrollback
|
||||
.blocks
|
||||
.first()
|
||||
.expect("expected first scrollback block");
|
||||
let json: Value = serde_json::from_slice(&first_block.raw).expect("valid scrollback json");
|
||||
|
||||
// Capture the expected bytes from the model so we can assert exact JSON array contents.
|
||||
let completed_block = model.block_list().block_at(1.into()).unwrap();
|
||||
let expected: SerializedBlock = completed_block.into();
|
||||
|
||||
let expected_command: Vec<Value> = expected
|
||||
.stylized_command
|
||||
.iter()
|
||||
.map(|&b| Value::from(b))
|
||||
.collect();
|
||||
let expected_output: Vec<Value> = expected
|
||||
.stylized_output
|
||||
.iter()
|
||||
.map(|&b| Value::from(b))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
json.get("stylized_command"),
|
||||
Some(&Value::Array(expected_command)),
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("stylized_output"),
|
||||
Some(&Value::Array(expected_output)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrollback_deserialization() {
|
||||
let raw = serde_json::json!({
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"stylized_command": [104, 101, 108, 108, 111],
|
||||
"stylized_output": [119, 111, 114, 108, 100],
|
||||
"pwd": null,
|
||||
"git_head": null,
|
||||
"virtual_env": null,
|
||||
"conda_env": null,
|
||||
"node_version": null,
|
||||
"exit_code": 0,
|
||||
"did_execute": true,
|
||||
"completed_ts": null,
|
||||
"start_ts": null,
|
||||
"ps1": null,
|
||||
"rprompt": null,
|
||||
"honor_ps1": false,
|
||||
"is_background": false,
|
||||
"session_id": null,
|
||||
"shell_host": null,
|
||||
"prompt_snapshot": null,
|
||||
"ai_metadata": null
|
||||
});
|
||||
|
||||
let scrollback = Scrollback {
|
||||
blocks: vec![ScrollbackBlock {
|
||||
raw: serde_json::to_vec(&raw).expect("serialize scrollback json"),
|
||||
}],
|
||||
is_alt_screen_active: false,
|
||||
};
|
||||
|
||||
let decoded = decode_scrollback(&scrollback);
|
||||
|
||||
assert_eq!(decoded.len(), 1);
|
||||
assert_eq!(decoded[0].stylized_command, b"hello");
|
||||
assert_eq!(decoded[0].stylized_output, b"world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_scrollback() {
|
||||
let session_id = 42.into();
|
||||
let mut active_block = SerializedBlock::new_active_block_for_test();
|
||||
active_block.session_id = Some(session_id);
|
||||
|
||||
let scrollback_blocks = &[
|
||||
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
||||
SerializedBlock::new_for_test("block2".into(), "block2".into()),
|
||||
// We expect the active block as part of scrollback to get the prompt.
|
||||
active_block,
|
||||
];
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = terminal_model_for_viewer(channel_event_proxy);
|
||||
model.load_shared_session_scrollback(scrollback_blocks, false);
|
||||
|
||||
// 4 blocks: first is the bootstrap block, the next two are completed scrollback blocks.
|
||||
// The last is the active block, whose prompt came from the last scrollback.
|
||||
assert_eq!(model.block_list().blocks().len(), 4);
|
||||
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(1.into())
|
||||
.unwrap()
|
||||
.command_to_string(),
|
||||
"block1"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(1.into())
|
||||
.unwrap()
|
||||
.output_to_string(),
|
||||
"block1"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(2.into())
|
||||
.unwrap()
|
||||
.command_to_string(),
|
||||
"block2"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(2.into())
|
||||
.unwrap()
|
||||
.output_to_string(),
|
||||
"block2"
|
||||
);
|
||||
|
||||
// The last scrollback block is the active block and contains the prompt.
|
||||
assert_eq!(model.block_list().active_block_index(), 3.into());
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
assert!(!model.block_list().active_block().started());
|
||||
assert_eq!(
|
||||
model.block_list().active_block().session_id(),
|
||||
Some(session_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_scrollback_in_alt_screen() {
|
||||
let scrollback_blocks = &[
|
||||
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
||||
// We expect the active block as part of scrollback to get the prompt.
|
||||
SerializedBlock::new_active_block_for_test(),
|
||||
];
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut model = terminal_model_for_viewer(channel_event_proxy);
|
||||
model.load_shared_session_scrollback(scrollback_blocks, true);
|
||||
|
||||
// 3 blocks: first is the bootstrap block, the second is the completed scrollback blocks.
|
||||
// The last is the active block, whose prompt came from the last scrollback.
|
||||
assert_eq!(model.block_list().blocks().len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(1.into())
|
||||
.unwrap()
|
||||
.command_to_string(),
|
||||
"block1"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(1.into())
|
||||
.unwrap()
|
||||
.output_to_string(),
|
||||
"block1"
|
||||
);
|
||||
|
||||
// The last scrollback block is the active block and contains the prompt.
|
||||
assert_lines_approx_eq!(
|
||||
model
|
||||
.block_list()
|
||||
.block_at(2.into())
|
||||
.unwrap()
|
||||
.height(&AgentViewState::Inactive),
|
||||
0.
|
||||
);
|
||||
assert!(!model.block_list().block_at(2.into()).unwrap().started());
|
||||
|
||||
// Make sure we're in the alt screen.
|
||||
assert!(model.is_alt_screen_active());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use futures::stream::AbortHandle;
|
||||
use std::time::Duration;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
const DEFAULT_PING_FREQUENCY: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A simple heartbeat mechanism to trigger ping notifications
|
||||
/// on some cadence and to maintain a idle timer.
|
||||
pub struct Heartbeat {
|
||||
/// How often we want to trigger a [`Event::SendPing`] event.
|
||||
ping_frequency: Duration,
|
||||
|
||||
/// The duration that we want to wait before firing a [`Event::Idle`] event.
|
||||
/// To extend the timer by this duration, use [`Self::reset_idle_timeout`].
|
||||
idle_timeout: Duration,
|
||||
|
||||
idle_timeout_abort_handle: Option<AbortHandle>,
|
||||
periodic_ping_abort_handle: Option<AbortHandle>,
|
||||
}
|
||||
|
||||
impl Default for Heartbeat {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout_abort_handle: None,
|
||||
periodic_ping_abort_handle: None,
|
||||
ping_frequency: DEFAULT_PING_FREQUENCY,
|
||||
idle_timeout: DEFAULT_IDLE_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Heartbeat {
|
||||
pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
|
||||
self.idle_timeout = idle_timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_ping_frequency(mut self, ping_frequency: Duration) -> Self {
|
||||
self.ping_frequency = ping_frequency;
|
||||
self
|
||||
}
|
||||
|
||||
/// Starts the periodic ping and the idle timeout tracker.
|
||||
pub fn start(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.reset_idle_timeout(ctx);
|
||||
self.periodic_ping(ctx);
|
||||
}
|
||||
|
||||
/// Resets the idle timeout to expire after [`Self::idle_timeout`] from now.
|
||||
pub fn reset_idle_timeout(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.idle_timeout_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let idle_timeout = self.idle_timeout;
|
||||
let handle = ctx.spawn(
|
||||
async move { Timer::after(idle_timeout).await },
|
||||
|me, _, ctx| {
|
||||
// If the heartbeat has become idle, then don't ping anymore.
|
||||
if let Some(handle) = me.periodic_ping_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
ctx.emit(Event::Idle);
|
||||
},
|
||||
);
|
||||
self.idle_timeout_abort_handle = Some(handle.abort_handle());
|
||||
}
|
||||
|
||||
/// Emits a [`Event::SendPing`] event based on [`Self::ping_frequency`].
|
||||
fn periodic_ping(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// TODO: this would be simpler with a `spawn_stream_local`
|
||||
// if our async timer supported an [`interval` API](https://docs.rs/async-io/latest/async_io/struct.Timer.html#method.interval).
|
||||
let ping_frequency = self.ping_frequency;
|
||||
let handle = ctx.spawn(
|
||||
async move { Timer::after(ping_frequency).await },
|
||||
|me, _, ctx| {
|
||||
ctx.emit(Event::Ping);
|
||||
me.periodic_ping(ctx);
|
||||
},
|
||||
);
|
||||
self.periodic_ping_abort_handle = Some(handle.abort_handle());
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Ping,
|
||||
Idle,
|
||||
}
|
||||
|
||||
impl Entity for Heartbeat {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "heartbeat_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::{Event, Heartbeat};
|
||||
use std::time::Duration;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::App;
|
||||
|
||||
#[test]
|
||||
#[ignore = "Flakes in CI"]
|
||||
fn test_periodic_ping() {
|
||||
App::test((), |mut app| async move {
|
||||
let heartbeat =
|
||||
app.add_model(|_| Heartbeat::default().with_ping_frequency(Duration::from_millis(100)));
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let tx_clone = tx.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&heartbeat, move |_, event, _| {
|
||||
if matches!(event, Event::Ping) {
|
||||
tx_clone.try_send(()).expect("can send over channel");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start the heartbeat.
|
||||
heartbeat.update(&mut app, |heartbeat, ctx| heartbeat.start(ctx));
|
||||
|
||||
// After 50ms, there shouldn't have been any pings.
|
||||
Timer::after(Duration::from_millis(50)).await;
|
||||
assert_eq!(rx.len(), 0);
|
||||
|
||||
// After 150ms, there should have been 1 ping.
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
assert_eq!(rx.len(), 1);
|
||||
|
||||
// After 175ms, there still should have only been 1 ping.
|
||||
Timer::after(Duration::from_millis(25)).await;
|
||||
assert_eq!(rx.len(), 1);
|
||||
|
||||
// After 250ms, there should have been 2 pings in total.
|
||||
Timer::after(Duration::from_millis(75)).await;
|
||||
assert_eq!(rx.len(), 2);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Flakes in CI"]
|
||||
fn test_idle_timeout() {
|
||||
App::test((), |mut app| async move {
|
||||
let heartbeat =
|
||||
app.add_model(|_| Heartbeat::default().with_idle_timeout(Duration::from_millis(100)));
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&heartbeat, move |_, event, _| {
|
||||
if matches!(event, Event::Idle) {
|
||||
tx.try_send(()).expect("can send over channel");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start the heartbeat.
|
||||
heartbeat.update(&mut app, |heartbeat, ctx| heartbeat.start(ctx));
|
||||
|
||||
// The idle timeout should not have expired yet.
|
||||
Timer::after(Duration::from_millis(50)).await;
|
||||
assert_eq!(rx.len(), 0);
|
||||
|
||||
// Reset the idle timeout.
|
||||
heartbeat.update(
|
||||
&mut app,
|
||||
|heartbeat, ctx: &mut warpui::ModelContext<Heartbeat>| {
|
||||
heartbeat.reset_idle_timeout(ctx)
|
||||
},
|
||||
);
|
||||
|
||||
// If the idle timeout was reset properly, then there should not be a idle event yet
|
||||
// even though one full idle timeout has elapsed since `start`.
|
||||
Timer::after(Duration::from_millis(75)).await;
|
||||
assert_eq!(rx.len(), 0);
|
||||
|
||||
// One full idle timeout has elapsed since the last reset, so
|
||||
// we should have received an idle event.
|
||||
Timer::after(Duration::from_millis(75)).await;
|
||||
assert_eq!(rx.len(), 1);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod heartbeat;
|
||||
@@ -0,0 +1,674 @@
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::pane_group::{PaneHeaderAction, PaneHeaderCustomAction};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
};
|
||||
use instant::Duration;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use session_sharing_protocol::common::{ParticipantId, ParticipantInfo, Role};
|
||||
use session_sharing_protocol::sharer::RoleUpdateReason;
|
||||
use warpui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use warpui::{
|
||||
accessibility::AccessibilityContent,
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Fill, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use warpui::{FocusContext, ViewHandle};
|
||||
|
||||
use super::render_util::non_hoverable_participant_avatar;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HoveredElement {
|
||||
Avatar,
|
||||
ContextMenu,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParticipantAvatarAction {
|
||||
ScrollToSharedSessionParticipant {
|
||||
participant_id: ParticipantId,
|
||||
},
|
||||
UpdateRole {
|
||||
participant_id: ParticipantId,
|
||||
role: Role,
|
||||
},
|
||||
OpenTooltip,
|
||||
CloseTooltip,
|
||||
/// Opens the context menu on hover
|
||||
HoveredIn(HoveredElement),
|
||||
/// Closes context menu only if both elements
|
||||
/// have been hovered out of
|
||||
HoveredOut(HoveredElement),
|
||||
}
|
||||
|
||||
pub enum ParticipantAvatarEvent {
|
||||
ScrollToSharedSessionParticipant {
|
||||
participant_id: ParticipantId,
|
||||
},
|
||||
UpdateRole {
|
||||
participant_id: ParticipantId,
|
||||
role: Role,
|
||||
},
|
||||
MenuOpened {
|
||||
participant_id: ParticipantId,
|
||||
},
|
||||
MenuClosed,
|
||||
}
|
||||
|
||||
pub struct ParticipantAvatarView {
|
||||
// Field from role of [`PresenceManager`]
|
||||
// Indicates whether we ourselves are the sharer
|
||||
is_manager_sharer: bool,
|
||||
|
||||
// Fields from [`Participant`] needed for rendering
|
||||
participant_id: ParticipantId,
|
||||
display_name: String,
|
||||
image_url: Option<String>,
|
||||
participant_color: ColorU,
|
||||
is_muted: bool,
|
||||
role: Option<Role>,
|
||||
|
||||
// Mouse state to handle hover and click on avatar
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
|
||||
// Context menu fields
|
||||
menu: ViewHandle<Menu<ParticipantAvatarAction>>,
|
||||
is_menu_open: bool,
|
||||
is_menu_hovered: bool,
|
||||
is_avatar_hovered: bool,
|
||||
menu_mouse_state_handle: MouseStateHandle,
|
||||
close_menu_abort_handle: Option<SpawnedFutureHandle>,
|
||||
// Avatar context menu shouldn't trigger
|
||||
// while the pane header overflow menu is open
|
||||
is_pane_header_overflow_menu_open: bool,
|
||||
}
|
||||
|
||||
impl ParticipantAvatarView {
|
||||
pub fn new(
|
||||
is_manager_sharer: bool,
|
||||
info: ParticipantInfo,
|
||||
participant_color: ColorU,
|
||||
is_muted: bool,
|
||||
role: Option<Role>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let menu = ctx.add_typed_action_view(|_| Menu::new().with_width(170.));
|
||||
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
|
||||
me.handle_menu_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
is_manager_sharer,
|
||||
participant_id: info.id,
|
||||
display_name: info.profile_data.display_name,
|
||||
image_url: info.profile_data.photo_url,
|
||||
participant_color,
|
||||
is_muted,
|
||||
role,
|
||||
mouse_state_handle: Default::default(),
|
||||
menu,
|
||||
is_menu_open: false,
|
||||
is_menu_hovered: false,
|
||||
is_avatar_hovered: false,
|
||||
menu_mouse_state_handle: Default::default(),
|
||||
close_menu_abort_handle: None,
|
||||
is_pane_header_overflow_menu_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_participant_id(&mut self, id: ParticipantId) {
|
||||
self.participant_id = id;
|
||||
}
|
||||
|
||||
pub fn set_display_name(&mut self, name: String) {
|
||||
self.display_name = name;
|
||||
}
|
||||
|
||||
pub fn set_image_url(&mut self, path: Option<String>) {
|
||||
self.image_url = path;
|
||||
}
|
||||
|
||||
pub fn set_participant_color(&mut self, color: ColorU) {
|
||||
self.participant_color = color;
|
||||
}
|
||||
|
||||
pub fn set_is_muted(&mut self, is_muted: bool) {
|
||||
self.is_muted = is_muted;
|
||||
}
|
||||
|
||||
pub fn set_role(&mut self, role: Option<Role>) {
|
||||
self.role = role;
|
||||
}
|
||||
|
||||
pub fn set_is_pane_header_overflow_menu_open(&mut self, is_open: bool) {
|
||||
self.is_pane_header_overflow_menu_open = is_open;
|
||||
}
|
||||
|
||||
pub fn is_menu_open(&self) -> bool {
|
||||
self.is_menu_open
|
||||
}
|
||||
|
||||
fn context_menu_items(&self) -> Vec<MenuItem<ParticipantAvatarAction>> {
|
||||
let participant_id = self.participant_id.clone();
|
||||
let mut items = vec![MenuItemFields::new(self.display_name.clone())
|
||||
.with_disabled(true)
|
||||
.into_item()];
|
||||
|
||||
match self.role {
|
||||
Some(Role::Reader) => items.extend([MenuItemFields::new("Make editor")
|
||||
.with_on_select_action(ParticipantAvatarAction::UpdateRole {
|
||||
participant_id,
|
||||
role: Role::Executor,
|
||||
})
|
||||
.into_item()]),
|
||||
Some(Role::Executor) => items.extend([MenuItemFields::new("Make viewer")
|
||||
.with_on_select_action(ParticipantAvatarAction::UpdateRole {
|
||||
participant_id,
|
||||
role: Role::Reader,
|
||||
})
|
||||
.into_item()]),
|
||||
// Sharer does not have context menu
|
||||
_ => {}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
|
||||
if let MenuEvent::Close { .. } = event {
|
||||
self.close_context_menu(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_menu_open = true;
|
||||
self.menu.update(ctx, |menu, ctx| {
|
||||
let items = self.context_menu_items();
|
||||
menu.set_items(items, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn close_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_edit_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let background = appearance.theme().surface_3();
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::Edit
|
||||
.to_warpui_icon(appearance.theme().foreground())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(8.)
|
||||
.with_width(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_background(background)
|
||||
.with_corner_radius(CornerRadius::with_all(
|
||||
warpui::elements::Radius::Percentage(50.),
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to render avatar context menu.
|
||||
/// Handles events on hover.
|
||||
fn render_menu(&self) -> Box<dyn Element> {
|
||||
let is_menu_hovered = self.is_menu_hovered;
|
||||
Hoverable::new(self.menu_mouse_state_handle.clone(), |_| {
|
||||
ChildView::new(&self.menu).finish()
|
||||
})
|
||||
.on_hover(move |mouse_in, ctx, _, _| {
|
||||
if mouse_in & !is_menu_hovered {
|
||||
// Ensure menu isn't already being hovered over
|
||||
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn(
|
||||
HoveredElement::ContextMenu,
|
||||
));
|
||||
} else if !mouse_in {
|
||||
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut(
|
||||
HoveredElement::ContextMenu,
|
||||
));
|
||||
}
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to render non-hoverable participant avatar.
|
||||
/// Specifically handles adding edit icon to participants with `Role::Executor`.
|
||||
fn render_participant_avatar(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let is_executor = self.role.is_some_and(|r| r.can_execute());
|
||||
let avatar = non_hoverable_participant_avatar(
|
||||
self.display_name.clone(),
|
||||
self.image_url.clone(),
|
||||
self.participant_color,
|
||||
self.is_muted,
|
||||
is_executor,
|
||||
app,
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
avatar,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
if is_executor {
|
||||
let icon = self.render_edit_icon(appearance);
|
||||
stack.add_positioned_child(
|
||||
icon,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::BottomRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(stack.finish())
|
||||
.with_min_height(20.)
|
||||
.with_min_width(20.)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(2.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to render hoverable participant avatar.
|
||||
/// Handles hover and click events.
|
||||
fn render_hoverable_participant_avatar(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let participant_id = self.participant_id.clone();
|
||||
let is_manager_sharer = self.is_manager_sharer;
|
||||
let is_avatar_hovered = self.is_avatar_hovered;
|
||||
|
||||
Hoverable::new(self.mouse_state_handle.clone(), |_| {
|
||||
self.render_participant_avatar(appearance, app)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_hover(move |mouse_in, ctx, _, _| {
|
||||
match (mouse_in, is_manager_sharer) {
|
||||
(true, true) => {
|
||||
if !is_avatar_hovered {
|
||||
ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn(
|
||||
HoveredElement::Avatar,
|
||||
))
|
||||
}
|
||||
}
|
||||
(true, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::OpenTooltip),
|
||||
(false, true) => ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut(
|
||||
HoveredElement::Avatar,
|
||||
)),
|
||||
(false, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::CloseTooltip),
|
||||
};
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ParticipantAvatarAction::ScrollToSharedSessionParticipant {
|
||||
participant_id: participant_id.clone(),
|
||||
});
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn role(&self) -> Option<Role> {
|
||||
self.role
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ParticipantAvatarView {
|
||||
type Event = ParticipantAvatarEvent;
|
||||
}
|
||||
|
||||
impl View for ParticipantAvatarView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ParticipantAvatar"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_contents(&self, _ctx: &AppContext) -> Option<AccessibilityContent> {
|
||||
// TO DO
|
||||
None
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let avatar_hoverable = self.render_hoverable_participant_avatar(appearance, app);
|
||||
let mut stack = Stack::new().with_child(avatar_hoverable);
|
||||
|
||||
// Add tooltip if hovering over avatars as viewer
|
||||
if !self.is_manager_sharer && self.is_avatar_hovered {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_tooltip(self.display_name.clone(), appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
// Render context menu if hovering over viewer avatar as a sharer
|
||||
} else if self.is_manager_sharer
|
||||
&& self.is_menu_open
|
||||
&& !self.is_pane_header_overflow_menu_open
|
||||
&& self.role.is_some()
|
||||
{
|
||||
stack.add_positioned_overlay_child(
|
||||
self.render_menu(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ParticipantAvatarView {
|
||||
type Action = ParticipantAvatarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &ParticipantAvatarAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ParticipantAvatarAction::ScrollToSharedSessionParticipant { participant_id } => {
|
||||
ctx.emit(ParticipantAvatarEvent::ScrollToSharedSessionParticipant {
|
||||
participant_id: participant_id.clone(),
|
||||
});
|
||||
}
|
||||
ParticipantAvatarAction::UpdateRole {
|
||||
participant_id,
|
||||
role,
|
||||
} => {
|
||||
ctx.emit(ParticipantAvatarEvent::UpdateRole {
|
||||
participant_id: participant_id.clone(),
|
||||
role: *role,
|
||||
});
|
||||
}
|
||||
ParticipantAvatarAction::OpenTooltip => {
|
||||
self.is_avatar_hovered = true;
|
||||
}
|
||||
ParticipantAvatarAction::CloseTooltip => {
|
||||
self.is_avatar_hovered = false;
|
||||
}
|
||||
ParticipantAvatarAction::HoveredIn(menu_source) => {
|
||||
// Abort closing timer on open
|
||||
if let Some(old_abort_handle) = self.close_menu_abort_handle.take() {
|
||||
old_abort_handle.abort();
|
||||
}
|
||||
// Update hover state
|
||||
match menu_source {
|
||||
HoveredElement::Avatar => self.is_avatar_hovered = true,
|
||||
HoveredElement::ContextMenu => self.is_menu_hovered = true,
|
||||
}
|
||||
self.open_context_menu(ctx);
|
||||
ctx.emit(ParticipantAvatarEvent::MenuOpened {
|
||||
participant_id: self.participant_id.clone(),
|
||||
});
|
||||
}
|
||||
ParticipantAvatarAction::HoveredOut(menu_source) => {
|
||||
// Reset timer, if old one is still in progress
|
||||
if let Some(old_abort_handle) = self.close_menu_abort_handle.take() {
|
||||
old_abort_handle.abort();
|
||||
}
|
||||
// Update hover state
|
||||
match menu_source {
|
||||
HoveredElement::Avatar => self.is_avatar_hovered = false,
|
||||
HoveredElement::ContextMenu => self.is_menu_hovered = false,
|
||||
}
|
||||
let should_close_menu = !self.is_avatar_hovered && !self.is_menu_hovered;
|
||||
|
||||
// Add delay before closing
|
||||
if should_close_menu {
|
||||
let close_menu_abort_handle = ctx.spawn_abortable(
|
||||
Timer::after(Duration::from_millis(100)),
|
||||
move |me, _, ctx| {
|
||||
if should_close_menu {
|
||||
me.close_context_menu(ctx);
|
||||
ctx.emit(ParticipantAvatarEvent::MenuClosed);
|
||||
}
|
||||
},
|
||||
|_, _| (),
|
||||
);
|
||||
self.close_menu_abort_handle = Some(close_menu_abort_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_tooltip(label: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let tooltip_background = appearance.theme().tooltip_background();
|
||||
appearance
|
||||
.ui_builder()
|
||||
.tool_tip(label)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
background: Some(Fill::Solid(tooltip_background)),
|
||||
font_color: Some(appearance.theme().background().into_solid()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to render a button that revokes executor role from all viewers.
|
||||
pub fn render_revoke_all_button(
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let edit = Icon::Edit
|
||||
.to_warpui_icon(appearance.theme().foreground())
|
||||
.finish();
|
||||
let slash = Icon::Slash
|
||||
.to_warpui_icon(appearance.theme().terminal_colors().normal.red.into())
|
||||
.finish();
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
stack.add_positioned_child(
|
||||
edit,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
stack.add_positioned_child(
|
||||
slash,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
Hoverable::new(mouse_state_handle, |state| {
|
||||
let mut button = Container::new(
|
||||
ConstrainedBox::new(stack.finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_border(Border::all(1.))
|
||||
.with_uniform_padding(4.)
|
||||
.with_margin_right(2.);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
if state.is_hovered() {
|
||||
let background_color = if state.is_clicked() {
|
||||
appearance.theme().background().into()
|
||||
} else {
|
||||
appearance.theme().surface_2().into()
|
||||
};
|
||||
button = button
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background_color(background_color)
|
||||
.with_border(
|
||||
Border::all(1.).with_border_color(appearance.theme().surface_3().into()),
|
||||
);
|
||||
|
||||
stack.add_positioned_child(
|
||||
render_tooltip("Revoke all edit permissions".to_string(), appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 3.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ChildAnchor::TopMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.add_child(button.finish());
|
||||
stack.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
// We have to dispatch a pane header action because the button is rendered in the pane header.
|
||||
ctx.dispatch_typed_action(PaneHeaderCustomAction::<TerminalAction, TerminalAction>(
|
||||
TerminalAction::MakeAllParticipantsReaders {
|
||||
reason: RoleUpdateReason::UpdatedBySharer,
|
||||
},
|
||||
));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to render a button that indicates a viewer's role.
|
||||
pub fn render_viewer_role_button(
|
||||
role: Option<Role>,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
menu_handle: Option<ViewHandle<Menu<PaneHeaderAction<TerminalAction, TerminalAction>>>>,
|
||||
is_menu_open: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon = match role {
|
||||
Some(role) if role.can_execute() => Icon::Edit,
|
||||
_ => Icon::Eye,
|
||||
};
|
||||
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let mut stack = Stack::new();
|
||||
let button = icon_button(appearance, icon, false, mouse_state_handle.clone())
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Change role".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
// We have to dispatch a pane header action because the button is rendered in the pane header.
|
||||
ctx.dispatch_typed_action(PaneHeaderCustomAction::<TerminalAction, TerminalAction>(
|
||||
TerminalAction::OpenSharedSessionViewerRoleMenu,
|
||||
));
|
||||
})
|
||||
.finish();
|
||||
|
||||
stack.add_child(button);
|
||||
|
||||
if let Some(menu) = menu_handle {
|
||||
if is_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Container::new(stack.finish()).with_margin_left(8.).finish()
|
||||
}
|
||||
|
||||
/// Helper function to render participant avatar list and role buttons in the pane header.
|
||||
pub fn render_participants_and_role_elements(
|
||||
participants: Vec<ViewHandle<ParticipantAvatarView>>,
|
||||
role: Option<Role>,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
menu_handle: Option<ViewHandle<Menu<PaneHeaderAction<TerminalAction, TerminalAction>>>>,
|
||||
is_menu_open: bool,
|
||||
hide_role_change_button: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Only render button for sharer (client is sharer iff role is none)
|
||||
// when there exists viewers that are executors.
|
||||
let num_executors = participants
|
||||
.iter()
|
||||
.filter(|participant| {
|
||||
participant
|
||||
.as_ref(app)
|
||||
.role()
|
||||
.is_some_and(|r| r.can_execute())
|
||||
})
|
||||
.count();
|
||||
if role.is_none() && num_executors > 0 {
|
||||
row.add_child(render_revoke_all_button(
|
||||
mouse_state_handle.clone(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
for participant in participants.iter() {
|
||||
row.add_child(
|
||||
Container::new(ChildView::new(participant).finish())
|
||||
.with_horizontal_margin(1.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// Only render button for viewer, unless hide_role_change_button is true
|
||||
// (e.g., in cloud mode conversations where role changes are not supported)
|
||||
if role.is_some() && !hide_role_change_button {
|
||||
row.add_child(render_viewer_role_button(
|
||||
role,
|
||||
mouse_state_handle.clone(),
|
||||
menu_handle.clone(),
|
||||
is_menu_open,
|
||||
appearance,
|
||||
));
|
||||
Container::new(row.finish()).finish()
|
||||
} else {
|
||||
row.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use session_sharing_protocol::common::{Guest, PendingGuest, Role, SessionId, TeamAclData};
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::drive::sharing::SharingAccessLevel;
|
||||
pub struct SessionPermissionsManager {}
|
||||
|
||||
impl SessionPermissionsManager {
|
||||
pub(crate) fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub(crate) fn updated_guests(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
session_id: SessionId,
|
||||
guests: Vec<Guest>,
|
||||
pending_guests: Vec<PendingGuest>,
|
||||
) {
|
||||
ctx.emit(SessionPermissionsEvent::GuestsUpdated {
|
||||
session_id,
|
||||
guests,
|
||||
pending_guests,
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn updated_link_permissions(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
role: Option<Role>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let access_level = role.map(|role| role.into());
|
||||
ctx.emit(SessionPermissionsEvent::LinkPermissionsUpdated {
|
||||
session_id,
|
||||
access_level,
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the team ACL for the given session. For now, this assumes that
|
||||
/// sessions can have only one team ACL.
|
||||
pub(crate) fn updated_team_permissions(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
team_acl: Option<TeamAclData>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(SessionPermissionsEvent::TeamPermissionsUpdated {
|
||||
session_id,
|
||||
team_acl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SessionPermissionsEvent {
|
||||
GuestsUpdated {
|
||||
session_id: SessionId,
|
||||
guests: Vec<Guest>,
|
||||
pending_guests: Vec<PendingGuest>,
|
||||
},
|
||||
LinkPermissionsUpdated {
|
||||
session_id: SessionId,
|
||||
access_level: Option<SharingAccessLevel>,
|
||||
},
|
||||
TeamPermissionsUpdated {
|
||||
session_id: SessionId,
|
||||
team_acl: Option<TeamAclData>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for SessionPermissionsManager {
|
||||
type Event = SessionPermissionsEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for SessionPermissionsManager {}
|
||||
@@ -0,0 +1,785 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
iter,
|
||||
};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures_util::future::join_all;
|
||||
use itertools::{Either, Itertools};
|
||||
use pathfinder_color::ColorU;
|
||||
use rand::Rng;
|
||||
use session_sharing_protocol::common::{
|
||||
InputReplicaId, ParticipantInfo, ParticipantList, ParticipantPresenceUpdate, PresenceUpdate,
|
||||
Role, RoleRequestId, Selection,
|
||||
};
|
||||
|
||||
use asset_cache::AssetCacheExt as _;
|
||||
use warpui::{
|
||||
assets::asset_cache::{AssetCache, AssetState},
|
||||
image_cache::ImageType,
|
||||
r#async::SpawnedFutureHandle,
|
||||
AppContext, Entity, ModelContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
editor::{CursorColors, PeerSelectionData},
|
||||
terminal::model::{block::BlockId, blocks::BlockList, terminal_model::BlockIndex},
|
||||
util::color::coloru_with_opacity,
|
||||
};
|
||||
|
||||
/// Selections have 25% opacity.
|
||||
pub fn text_selection_color(participant_color: ColorU) -> ColorU {
|
||||
coloru_with_opacity(participant_color, 25)
|
||||
}
|
||||
|
||||
pub const MUTED_PARTICIPANT_COLOR: ColorU = ColorU {
|
||||
r: 176,
|
||||
g: 176,
|
||||
b: 176,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub const MUTED_AVATAR_BORDER_COLOR: ColorU = ColorU {
|
||||
r: 138,
|
||||
g: 138,
|
||||
b: 138,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// A set of pre-assigned colors that we use for shared session participants.
|
||||
/// These come from https://www.figma.com/file/chk9pwt35jTJhf9KnHmZyE/Components?type=design&node-id=1650-1410&mode=design&t=RTHbE9G6NLhFRqLQ-0.
|
||||
const PRESET_COLORS: &[ColorU] = &[
|
||||
ColorU {
|
||||
r: 93,
|
||||
g: 202,
|
||||
b: 60,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 174,
|
||||
g: 67,
|
||||
b: 255,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 224,
|
||||
g: 222,
|
||||
b: 19,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 255,
|
||||
g: 125,
|
||||
b: 38,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 68,
|
||||
g: 233,
|
||||
b: 237,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 54,
|
||||
g: 98,
|
||||
b: 236,
|
||||
a: 255,
|
||||
},
|
||||
ColorU {
|
||||
r: 255,
|
||||
g: 13,
|
||||
b: 226,
|
||||
a: 255,
|
||||
},
|
||||
];
|
||||
|
||||
/// Helper struct containing participant info and anything else necessary for rendering
|
||||
/// for an present participant.
|
||||
#[derive(Clone)]
|
||||
pub struct Participant {
|
||||
pub info: ParticipantInfo,
|
||||
|
||||
/// The color assigned to this participant
|
||||
pub color: ColorU,
|
||||
|
||||
/// Is None iff participant is sharer.
|
||||
pub role: Option<Role>,
|
||||
}
|
||||
|
||||
impl Participant {
|
||||
pub fn id(&self) -> &ParticipantId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn input_replica_id(&self) -> &InputReplicaId {
|
||||
&self.info.profile_data.input_replica_id
|
||||
}
|
||||
|
||||
/// Returns the selected block index that the avatar should be rendered at.
|
||||
/// This is the block at the top of the last continuous selection.
|
||||
/// Returns None if the participant doesn't have a block selected.
|
||||
pub fn get_selected_block_index_for_avatar(
|
||||
&self,
|
||||
block_list: &BlockList,
|
||||
) -> Option<BlockIndex> {
|
||||
let session_sharing_protocol::common::Selection::Blocks { block_ids } =
|
||||
&self.info.selection
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut block_index_for_avatar = None;
|
||||
// Sort selected block indices in decreasing order.
|
||||
let block_indices = block_ids
|
||||
.iter()
|
||||
.filter_map(|block_id| block_list.block_index_for_id(&(block_id.to_string().into())))
|
||||
.sorted_unstable()
|
||||
.rev();
|
||||
for idx in block_indices {
|
||||
let Some(block_index) = block_index_for_avatar else {
|
||||
block_index_for_avatar = Some(idx);
|
||||
continue;
|
||||
};
|
||||
// If this is part of the same continuous selection, update the index since we want the avatar at the top of the last continuous selection.
|
||||
if idx
|
||||
== std::convert::Into::<usize>::into(block_index)
|
||||
.saturating_sub(1)
|
||||
.into()
|
||||
{
|
||||
block_index_for_avatar = Some(idx);
|
||||
} else {
|
||||
// Once we reach a smaller index that's not part of the same continuous selection, return
|
||||
return block_index_for_avatar;
|
||||
}
|
||||
}
|
||||
block_index_for_avatar
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct containing presence information about a participant who selected a particular block.
|
||||
pub struct ParticipantAtSelectedBlock<'a> {
|
||||
/// The participant who selected the block.
|
||||
pub participant: &'a Participant,
|
||||
/// This block is the top of a continuous block selection by this participant.
|
||||
/// True for single selected block as well.
|
||||
pub is_top_of_continuous_selection: bool,
|
||||
/// This block is the bottom of a continuous block selection by this participant.
|
||||
/// True for single selected block as well.
|
||||
pub is_bottom_of_continuous_selection: bool,
|
||||
pub should_show_avatar: bool,
|
||||
}
|
||||
|
||||
/// A viewer who was once part of the session
|
||||
/// but no longer is.
|
||||
#[derive(Clone)]
|
||||
pub struct AbsentViewer {
|
||||
/// The last known info we had about the viewer.
|
||||
participant_info: ParticipantInfo,
|
||||
}
|
||||
|
||||
impl AbsentViewer {
|
||||
pub fn id(&self) -> &ParticipantId {
|
||||
&self.participant_info.id
|
||||
}
|
||||
|
||||
pub fn input_replica_id(&self) -> &InputReplicaId {
|
||||
&self.participant_info.profile_data.input_replica_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for assigning colors to shared session participants as they join and leave.
|
||||
/// This should contain the data needed to render presence-related UIs.
|
||||
/// The presence manager does not store participant data about ourselves, whether we are the sharer or viewer.
|
||||
pub struct PresenceManager {
|
||||
/// Our own Participant ID.
|
||||
id: ParticipantId,
|
||||
|
||||
/// Our own Firebase UID.
|
||||
firebase_uid: UserUid,
|
||||
|
||||
/// Our own role, None iff is sharer.
|
||||
pub role: Option<Role>,
|
||||
|
||||
/// Participant ID of the sharer.
|
||||
sharer_id: ParticipantId,
|
||||
|
||||
/// Is None iff we ourselves are the sharer.
|
||||
sharer: Option<Participant>,
|
||||
|
||||
/// The set of viewers who are still part of the session.
|
||||
///
|
||||
/// If we are ourselves a viewer, this map does _not_ include our own state.
|
||||
present_viewers: HashMap<ParticipantId, Participant>,
|
||||
|
||||
/// The set of viewers who were once part of the session but no longer are.
|
||||
/// By default, all of the `get_*` APIs that return a list of participants
|
||||
/// _do not_ include the absent viewers.
|
||||
absent_viewers: HashMap<ParticipantId, AbsentViewer>,
|
||||
|
||||
chosen_colors: HashSet<ColorU>,
|
||||
|
||||
/// Loading participants is a future because we may need to download an image.
|
||||
/// Note even if there is no image, the participant is still loaded as a future.
|
||||
load_participants_imgs_future_handle: Option<SpawnedFutureHandle>,
|
||||
|
||||
/// Whether we ourselves are attempting to reconnect to the server.
|
||||
/// If this is true, all avatars should have a muted color.
|
||||
is_reconnecting: bool,
|
||||
|
||||
// Map from block ID to the shared session participant IDs that have it selected.
|
||||
block_id_to_participants_selected: HashMap<BlockId, Vec<ParticipantId>>,
|
||||
|
||||
role_requests: HashMap<ParticipantId, RoleRequestId>,
|
||||
}
|
||||
|
||||
/// Returns the first available preset color, or a random color if all are taken.
|
||||
pub fn get_available_color(chosen_colors: &HashSet<ColorU>) -> ColorU {
|
||||
for color in PRESET_COLORS {
|
||||
if !chosen_colors.contains(color) {
|
||||
return *color;
|
||||
}
|
||||
}
|
||||
// If we ran out of colors, generate a random one.
|
||||
ColorU::new(
|
||||
rand::thread_rng().gen_range(0..=255),
|
||||
rand::thread_rng().gen_range(0..=255),
|
||||
rand::thread_rng().gen_range(0..=255),
|
||||
255,
|
||||
)
|
||||
}
|
||||
|
||||
impl PresenceManager {
|
||||
pub fn new_for_sharer(id: ParticipantId, firebase_uid: UserUid) -> Self {
|
||||
Self {
|
||||
id: id.clone(),
|
||||
firebase_uid,
|
||||
role: None,
|
||||
sharer_id: id,
|
||||
sharer: None,
|
||||
present_viewers: HashMap::new(),
|
||||
absent_viewers: HashMap::new(),
|
||||
chosen_colors: HashSet::new(),
|
||||
load_participants_imgs_future_handle: None,
|
||||
block_id_to_participants_selected: HashMap::new(),
|
||||
is_reconnecting: false,
|
||||
role_requests: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_viewer(
|
||||
id: ParticipantId,
|
||||
firebase_uid: UserUid,
|
||||
participants: ParticipantList,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
// Populate sharer info, remaining fields for sharer and viewer
|
||||
// will be populated in the call to `update_participants`.
|
||||
let mut chosen_colors = HashSet::new();
|
||||
let color = get_available_color(&chosen_colors);
|
||||
chosen_colors.insert(color);
|
||||
|
||||
let sharer = Participant {
|
||||
info: participants.sharer.info.clone(),
|
||||
color,
|
||||
role: None,
|
||||
};
|
||||
|
||||
let mut manager = Self {
|
||||
id,
|
||||
firebase_uid,
|
||||
role: Some(Role::default()),
|
||||
sharer_id: participants.sharer.info.id.clone(),
|
||||
sharer: Some(sharer),
|
||||
present_viewers: HashMap::new(),
|
||||
absent_viewers: HashMap::new(),
|
||||
chosen_colors,
|
||||
load_participants_imgs_future_handle: None,
|
||||
block_id_to_participants_selected: HashMap::new(),
|
||||
is_reconnecting: false,
|
||||
role_requests: HashMap::new(),
|
||||
};
|
||||
manager.update_participants(participants, ctx);
|
||||
manager
|
||||
}
|
||||
|
||||
/// Returns our own participant id.
|
||||
pub fn id(&self) -> ParticipantId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
/// Returns our own Firebase UID.
|
||||
pub fn firebase_uid(&self) -> UserUid {
|
||||
self.firebase_uid
|
||||
}
|
||||
|
||||
/// Returns the sharer's participant id.
|
||||
pub fn sharer_id(&self) -> ParticipantId {
|
||||
self.sharer_id.clone()
|
||||
}
|
||||
|
||||
/// Returns our own role, `None` iff we are the sharer.
|
||||
pub fn role(&self) -> Option<Role> {
|
||||
self.role
|
||||
}
|
||||
|
||||
/// Returns the viewer's role, if the viewer is known to us.
|
||||
pub fn viewer_role(&self, viewer_id: &ParticipantId) -> Option<Role> {
|
||||
self.present_viewers.get(viewer_id).and_then(|v| v.role)
|
||||
}
|
||||
|
||||
/// Returns a viewer's role request id given their participant id,
|
||||
/// `None` if the viewer does not have a pending request.
|
||||
pub fn get_role_request(&self, participant_id: &ParticipantId) -> Option<&RoleRequestId> {
|
||||
self.role_requests.get(participant_id)
|
||||
}
|
||||
|
||||
/// Returns the number of present viewers (not including ourselves).
|
||||
pub(crate) fn present_viewer_count(&self) -> usize {
|
||||
self.present_viewers.len()
|
||||
}
|
||||
|
||||
/// Returns the present viewers of this shared session, not including ourselves.
|
||||
/// There is no guarantee of the ordering of viewers, so callers should sort by ID for a stable ordering.
|
||||
pub fn get_present_viewers(&self) -> impl Iterator<Item = &Participant> {
|
||||
self.present_viewers.values()
|
||||
}
|
||||
|
||||
/// Returns the sharer of this shared session.
|
||||
/// Returns None if we are the sharer ourselves (we should not need presence data for ourselves).
|
||||
pub fn get_sharer(&self) -> Option<&Participant> {
|
||||
self.sharer.as_ref()
|
||||
}
|
||||
|
||||
/// Returns all present participants of this shared session, including sharer and viewers,
|
||||
/// but not including ourselves.
|
||||
pub fn all_present_participants(&self) -> impl Iterator<Item = &Participant> {
|
||||
if let Some(sharer) = self.get_sharer() {
|
||||
return Either::Left(iter::once(sharer).chain(self.get_present_viewers()));
|
||||
}
|
||||
Either::Right(self.get_present_viewers())
|
||||
}
|
||||
|
||||
/// Returns the participant identified by id iff the participant is present.
|
||||
pub fn get_participant(&self, id: &ParticipantId) -> Option<&Participant> {
|
||||
if let Some(viewer) = self.present_viewers.get(id) {
|
||||
return Some(viewer);
|
||||
} else if let Some(sharer) = self.sharer.as_ref() {
|
||||
if self.sharer_id == *id {
|
||||
return Some(sharer);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the participants who have the block at the block index selected.
|
||||
pub fn get_participants_selected_block_index(
|
||||
&self,
|
||||
block_index: BlockIndex,
|
||||
block_list: &BlockList,
|
||||
) -> Vec<&Participant> {
|
||||
let Some(block) = block_list.block_at(block_index) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(participant_ids) = self.block_id_to_participants_selected.get(block.id()) else {
|
||||
return vec![];
|
||||
};
|
||||
participant_ids
|
||||
.iter()
|
||||
.filter_map(|id| self.get_participant(id))
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// Returns the participants who have the block at the block index selected,
|
||||
/// with some additional info helpful for rendering.
|
||||
pub fn get_participants_at_selected_block(
|
||||
&self,
|
||||
block_index: BlockIndex,
|
||||
block_list: &BlockList,
|
||||
) -> Vec<ParticipantAtSelectedBlock<'_>> {
|
||||
let participants_selected_this_block =
|
||||
self.get_participants_selected_block_index(block_index, block_list);
|
||||
if participants_selected_this_block.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let participant_ids_selected_prev_block = if block_index == 0.into() {
|
||||
HashSet::new()
|
||||
} else {
|
||||
HashSet::<_>::from_iter(
|
||||
self.get_participants_selected_block_index(block_index - 1.into(), block_list)
|
||||
.into_iter()
|
||||
.map(|p| p.info.id.clone()),
|
||||
)
|
||||
};
|
||||
let participant_ids_selected_next_block = HashSet::<_>::from_iter(
|
||||
self.get_participants_selected_block_index(block_index + 1.into(), block_list)
|
||||
.into_iter()
|
||||
.map(|p| p.info.id.clone()),
|
||||
);
|
||||
|
||||
participants_selected_this_block
|
||||
.into_iter()
|
||||
.map(|participant| {
|
||||
let should_show_avatar = participant
|
||||
.get_selected_block_index_for_avatar(block_list)
|
||||
.is_some_and(|idx| idx == block_index);
|
||||
ParticipantAtSelectedBlock {
|
||||
participant,
|
||||
is_top_of_continuous_selection: !participant_ids_selected_prev_block
|
||||
.contains(&participant.info.id),
|
||||
is_bottom_of_continuous_selection: !participant_ids_selected_next_block
|
||||
.contains(&participant.info.id),
|
||||
should_show_avatar,
|
||||
}
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
pub fn update_participants(
|
||||
&mut self,
|
||||
participants: ParticipantList,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// If there was a previous in-flight future updating participants, cancel it since our new list is more up to date.
|
||||
if let Some(old_abort_handle) = self.load_participants_imgs_future_handle.take() {
|
||||
old_abort_handle.abort();
|
||||
}
|
||||
|
||||
// The new or updated participants.
|
||||
let mut latest_participants = Vec::new();
|
||||
|
||||
// A list of futures. Each one represents a profile image that's being loaded for a participant.
|
||||
let mut participant_image_loading_futures = Vec::new();
|
||||
|
||||
// Update sharer info
|
||||
let incoming_sharer_info = participants.sharer.info;
|
||||
if let Some(sharer) = self.sharer.as_mut() {
|
||||
sharer.info = incoming_sharer_info.clone();
|
||||
|
||||
if let Some(future) = Self::when_profile_image_is_loaded(sharer, ctx) {
|
||||
participant_image_loading_futures.push(future);
|
||||
}
|
||||
latest_participants.push(sharer.clone());
|
||||
}
|
||||
|
||||
for viewer in participants.viewers {
|
||||
if !viewer.is_present {
|
||||
if let Some(viewer) = self.present_viewers.remove(&viewer.info.id) {
|
||||
self.chosen_colors.remove(&viewer.color);
|
||||
}
|
||||
self.absent_viewers.insert(
|
||||
viewer.info.id.clone(),
|
||||
AbsentViewer {
|
||||
participant_info: viewer.info,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let info = viewer.info;
|
||||
// Only store role data for ourselves.
|
||||
if info.id == self.id {
|
||||
self.role = Some(viewer.role);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this participant already existed, update the info and role
|
||||
// while keeping their color.
|
||||
if let Some(existing_participant) = self.present_viewers.get_mut(&info.id) {
|
||||
existing_participant.info = info;
|
||||
existing_participant.role = Some(viewer.role);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Otherwise, pick an available color and add them.
|
||||
let color = get_available_color(&self.chosen_colors);
|
||||
self.chosen_colors.insert(color);
|
||||
|
||||
let new_viewer = Participant {
|
||||
info,
|
||||
color,
|
||||
role: Some(viewer.role),
|
||||
};
|
||||
|
||||
if let Some(future) = Self::when_profile_image_is_loaded(&new_viewer, ctx) {
|
||||
participant_image_loading_futures.push(future);
|
||||
}
|
||||
latest_participants.push(new_viewer);
|
||||
}
|
||||
|
||||
// Spawn a future that waits for all the new profile images to be loaded into memory.
|
||||
let load_participants_future_handle = ctx.spawn(
|
||||
async move {
|
||||
join_all(participant_image_loading_futures).await;
|
||||
},
|
||||
|manager, _, ctx| {
|
||||
manager.on_participant_images_loaded(latest_participants, ctx);
|
||||
},
|
||||
);
|
||||
self.load_participants_imgs_future_handle = Some(load_participants_future_handle.clone());
|
||||
}
|
||||
|
||||
/// Returns a future that resolves when the participant's profile image is loaded. If the participant
|
||||
/// doesn't have a profile image OR their image is already available in memory, returns None.
|
||||
fn when_profile_image_is_loaded(
|
||||
participant: &Participant,
|
||||
app: &AppContext,
|
||||
) -> Option<BoxFuture<'static, ()>> {
|
||||
let url = participant.info.profile_data.photo_url.as_ref()?;
|
||||
let asset_cache = AssetCache::as_ref(app);
|
||||
|
||||
// Make a non-blocking check to see if the image is loaded yet. If the image hasn't been seen
|
||||
// before, this call spawns a task to fetch the bytes and parse it into an image.
|
||||
match asset_cache.load_asset_from_url::<ImageType>(url, None) {
|
||||
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_participant_presence(
|
||||
&mut self,
|
||||
update: ParticipantPresenceUpdate,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let participant = if self.sharer_id == update.participant_id {
|
||||
self.sharer.as_mut()
|
||||
} else {
|
||||
self.present_viewers.get_mut(&update.participant_id)
|
||||
};
|
||||
|
||||
let Some(participant) = participant else {
|
||||
if self.id != update.participant_id {
|
||||
log::warn!("Received shared session participant presence update for participant that doesn't exist");
|
||||
}
|
||||
return;
|
||||
};
|
||||
let PresenceUpdate::Selection(selection) = update.update;
|
||||
|
||||
// Selection info is needed for rendering remote cursors in input
|
||||
participant.info.selection = selection;
|
||||
self.refresh_block_id_to_participants_selected();
|
||||
}
|
||||
|
||||
pub fn update_participant_role(
|
||||
&mut self,
|
||||
participant_id: &ParticipantId,
|
||||
role: Role,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if participant_id == &self.id {
|
||||
self.role = Some(role);
|
||||
} else {
|
||||
let Some(participant) = self.present_viewers.get_mut(participant_id) else {
|
||||
log::warn!("Received shared session participant role update for participant that doesn't exist");
|
||||
return;
|
||||
};
|
||||
participant.role = Some(role);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_all_participants_readers(&mut self, _ctx: &mut ModelContext<Self>) {
|
||||
for viewer in self.present_viewers.values_mut() {
|
||||
viewer.role = Some(Role::Reader);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the sharer is notified of a role request from a viewer.
|
||||
pub fn on_role_requested(
|
||||
&mut self,
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// TODO: handle pending role requests on reconnection
|
||||
// Ensure only the sharer can update its role requests
|
||||
if self.sharer_id != self.id {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure viewer doesn't already have requested role
|
||||
if let Some(old_role) = self.viewer_role(&participant_id) {
|
||||
if role == old_role {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.role_requests
|
||||
.insert(participant_id.clone(), role_request_id.clone());
|
||||
}
|
||||
|
||||
/// Called when the sharer is notified of a cancelled role request
|
||||
pub fn on_role_request_cancelled(
|
||||
&mut self,
|
||||
participant_id: ParticipantId,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Ensure only the sharer can remove its role requests
|
||||
if self.sharer_id != self.id {
|
||||
return;
|
||||
}
|
||||
|
||||
self.role_requests.remove(&participant_id);
|
||||
}
|
||||
|
||||
/// Called as the sharer responds to a role request
|
||||
pub fn on_role_request_responded_to(
|
||||
&mut self,
|
||||
participant_id: ParticipantId,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Ensure only the sharer can remove its role requests
|
||||
if self.sharer_id != self.id {
|
||||
return;
|
||||
}
|
||||
|
||||
self.role_requests.remove(&participant_id);
|
||||
}
|
||||
|
||||
pub fn set_is_reconnecting(
|
||||
&mut self,
|
||||
is_self_reconnecting: bool,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.is_reconnecting = is_self_reconnecting;
|
||||
}
|
||||
|
||||
pub fn is_reconnecting(&self) -> bool {
|
||||
self.is_reconnecting
|
||||
}
|
||||
|
||||
/// Refreshes the block ID to participants selected cache to be consistent with the current participant data stored.
|
||||
fn refresh_block_id_to_participants_selected(&mut self) {
|
||||
self.block_id_to_participants_selected.clear();
|
||||
let participants = if self.sharer.is_some() {
|
||||
Either::Left(
|
||||
iter::once(self.sharer.as_ref().expect("sharer should exist"))
|
||||
.chain(self.present_viewers.values()),
|
||||
)
|
||||
} else {
|
||||
Either::Right(self.present_viewers.values())
|
||||
};
|
||||
for participant in participants {
|
||||
if let session_sharing_protocol::common::Selection::Blocks { block_ids } =
|
||||
&participant.info.selection
|
||||
{
|
||||
for block_id in block_ids {
|
||||
self.block_id_to_participants_selected
|
||||
.entry(block_id.to_string().into())
|
||||
.or_default()
|
||||
.push(participant.info.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_participant_images_loaded(
|
||||
&mut self,
|
||||
latest_participants: Vec<Participant>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Once all participant futures have completed, update the participant list and emit an event.
|
||||
for participant in latest_participants {
|
||||
if let session_sharing_protocol::common::Selection::Blocks { block_ids } =
|
||||
&participant.info.selection
|
||||
{
|
||||
for block_id in block_ids {
|
||||
self.block_id_to_participants_selected
|
||||
.entry(block_id.to_string().into())
|
||||
.or_default()
|
||||
.push(participant.info.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if participant.info.id == self.sharer_id {
|
||||
self.sharer = Some(participant);
|
||||
} else {
|
||||
self.present_viewers
|
||||
.insert(participant.info.id.clone(), participant);
|
||||
}
|
||||
}
|
||||
self.refresh_block_id_to_participants_selected();
|
||||
ctx.emit(Event::ParticipantListUpdated);
|
||||
}
|
||||
|
||||
pub fn input_data_for_participant(
|
||||
&self,
|
||||
participant: &Participant,
|
||||
) -> (InputReplicaId, PeerSelectionData) {
|
||||
let input_replica_id = participant.input_replica_id().clone();
|
||||
let participant_color = if self.is_reconnecting() {
|
||||
MUTED_PARTICIPANT_COLOR
|
||||
} else {
|
||||
participant.color
|
||||
};
|
||||
let colors = CursorColors {
|
||||
cursor: participant_color.into(),
|
||||
selection: text_selection_color(participant_color).into(),
|
||||
};
|
||||
|
||||
let cursor_data = PeerSelectionData {
|
||||
colors,
|
||||
display_name: participant.info.profile_data.display_name.clone(),
|
||||
image_url: participant.info.profile_data.photo_url.clone(),
|
||||
should_draw_cursors: matches!(participant.info.selection, Selection::None),
|
||||
};
|
||||
|
||||
(input_replica_id, cursor_data)
|
||||
}
|
||||
|
||||
pub fn absent_viewers(&self) -> impl Iterator<Item = &AbsentViewer> + '_ {
|
||||
self.absent_viewers.values()
|
||||
}
|
||||
|
||||
/// Returns a viewer's firebase uid, if the viewer is known to us.
|
||||
pub fn viewer_firebase_uid(&self, viewer_id: &ParticipantId) -> Option<UserUid> {
|
||||
if *viewer_id == self.id {
|
||||
return Some(self.firebase_uid);
|
||||
}
|
||||
|
||||
self.present_viewers
|
||||
.get(viewer_id)
|
||||
.map(|v| v.info.profile_data.firebase_uid.as_str())
|
||||
.or_else(|| {
|
||||
self.absent_viewers
|
||||
.get(viewer_id)
|
||||
.map(|v| v.participant_info.profile_data.firebase_uid.as_str())
|
||||
})
|
||||
.map(UserUid::new)
|
||||
}
|
||||
|
||||
/// Returns all of the present viewer IDs associated with the given Firebase
|
||||
/// UID, including ourselves if applicable.
|
||||
pub fn present_viewer_ids_for_uid(
|
||||
&self,
|
||||
viewer_uid: UserUid,
|
||||
) -> impl Iterator<Item = &ParticipantId> + '_ {
|
||||
let is_viewer_self = self.firebase_uid == viewer_uid;
|
||||
let viewer_ids = self
|
||||
.present_viewers
|
||||
.values()
|
||||
.filter(move |v| viewer_uid.as_string() == v.info.profile_data.firebase_uid)
|
||||
.map(|v| &v.info.id);
|
||||
viewer_ids.chain(is_viewer_self.then_some(&self.id))
|
||||
}
|
||||
|
||||
/// Returns a participant ID for a participant associated with the given
|
||||
/// Firebase UID.
|
||||
pub fn present_viewer_id_for_uid(&self, viewer_uid: UserUid) -> Option<&ParticipantId> {
|
||||
self.present_viewer_ids_for_uid(viewer_uid).next()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
ParticipantListUpdated,
|
||||
}
|
||||
|
||||
impl Entity for PresenceManager {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "presence_manager_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,374 @@
|
||||
use crate::auth::UserUid;
|
||||
use crate::terminal::model::ansi::{CommandFinishedValue, Handler};
|
||||
use crate::terminal::model::blocks::BlockList;
|
||||
use crate::terminal::model::test_utils::TestBlockListBuilder;
|
||||
use crate::terminal::shared_session::presence_manager::{PresenceManager, PRESET_COLORS};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
|
||||
use itertools::Itertools;
|
||||
use session_sharing_protocol::common::{
|
||||
ParticipantId, ParticipantInfo, ParticipantList, ProfileData, Role, Selection, Sharer, Viewer,
|
||||
};
|
||||
use warp_core::command::ExitCode;
|
||||
use warpui::App;
|
||||
|
||||
#[test]
|
||||
fn test_choosing_preset_colors() {
|
||||
App::test((), |mut app| async move {
|
||||
// Initialize with a sharer.
|
||||
let firebase_uid = UserUid::new("mock_firebase_uid");
|
||||
let presence_manager =
|
||||
app.add_model(|_| PresenceManager::new_for_sharer(ParticipantId::new(), firebase_uid));
|
||||
|
||||
let sharer_id = ParticipantId::new();
|
||||
let sharer = Sharer {
|
||||
info: ParticipantInfo {
|
||||
id: sharer_id.clone(),
|
||||
profile_data: ProfileData {
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let mut viewers = Vec::new();
|
||||
let sharer_clone = sharer.clone();
|
||||
let viewers_clone = viewers.clone();
|
||||
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
presence_manager.update_participants(
|
||||
ParticipantList {
|
||||
sharer: sharer_clone,
|
||||
viewers: viewers_clone,
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
// We ourselves are the sharer, so no color is saved
|
||||
presence_manager.read(&app, |presence_manager: &PresenceManager, _ctx| {
|
||||
let sharer = presence_manager.get_sharer();
|
||||
assert!(sharer.is_none());
|
||||
|
||||
let viewers = presence_manager.get_present_viewers().collect_vec();
|
||||
assert_eq!(viewers.len(), 0);
|
||||
});
|
||||
|
||||
// Add new viewers one-by-one. Each new viewer should take the next preset color, while existing viewers keep their colors.
|
||||
let viewer_ids = iter::repeat_with(ParticipantId::new).take(PRESET_COLORS.len());
|
||||
let mut id_to_expected_color = HashMap::new();
|
||||
|
||||
for (i, id) in viewer_ids.enumerate() {
|
||||
// Add a new viewer.
|
||||
viewers.push(Viewer {
|
||||
info: ParticipantInfo {
|
||||
id: id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
});
|
||||
let sharer_clone = sharer.clone();
|
||||
let viewers_clone = viewers.clone();
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
presence_manager.update_participants(
|
||||
ParticipantList {
|
||||
sharer: sharer_clone,
|
||||
viewers: viewers_clone,
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
// Expect the new viewer to take the next preset color, while continuing to expect old viewers to keep their colors.
|
||||
id_to_expected_color.insert(id, PRESET_COLORS[i]);
|
||||
presence_manager.read(&app, |presence_manager, _ctx| {
|
||||
let viewers = presence_manager.get_present_viewers().collect_vec();
|
||||
assert_eq!(viewers.len(), i + 1);
|
||||
for viewer in presence_manager.get_present_viewers() {
|
||||
let expected_color = *id_to_expected_color
|
||||
.get(&viewer.info.id)
|
||||
.expect("should have expected viewer ids only");
|
||||
assert_eq!(viewer.color, expected_color);
|
||||
assert!(matches!(viewer.role, Some(Role::Reader)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set the first viewer as no longer present, and add a new participant.
|
||||
viewers.get_mut(0).unwrap().is_present = false;
|
||||
assert!(!viewers.first().unwrap().is_present);
|
||||
let old_participant_id = viewers.first().unwrap().info.id.clone();
|
||||
let new_id = ParticipantId::new();
|
||||
viewers.push(Viewer {
|
||||
info: ParticipantInfo {
|
||||
id: new_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
});
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
presence_manager.update_participants(
|
||||
ParticipantList {
|
||||
sharer,
|
||||
viewers,
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
// The color previously taken by the first viewer should be reused for the new participant, while other participants keep their existing colors.
|
||||
let old_participant_color = id_to_expected_color
|
||||
.remove(&old_participant_id)
|
||||
.expect("old participant exists");
|
||||
id_to_expected_color.insert(new_id, old_participant_color);
|
||||
presence_manager.read(&app, |presence_manager, _ctx| {
|
||||
let viewers = presence_manager.get_present_viewers().collect_vec();
|
||||
assert_eq!(viewers.len(), PRESET_COLORS.len());
|
||||
for viewer in viewers {
|
||||
assert_eq!(
|
||||
viewer.color,
|
||||
*id_to_expected_color
|
||||
.get(&viewer.info.id)
|
||||
.expect("should have expected viewer ids only")
|
||||
);
|
||||
assert!(matches!(viewer.role, Some(Role::Reader)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dont_include_self_in_viewers() {
|
||||
App::test((), |mut app| async move {
|
||||
let self_id = ParticipantId::new();
|
||||
let self_firebase_uid = UserUid::new("mock_firebase_uid");
|
||||
|
||||
let sharer = Sharer {
|
||||
..Default::default()
|
||||
};
|
||||
let viewers = vec![
|
||||
Viewer {
|
||||
info: ParticipantInfo {
|
||||
id: self_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
},
|
||||
Viewer {
|
||||
info: ParticipantInfo {
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
},
|
||||
Viewer {
|
||||
info: ParticipantInfo {
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
},
|
||||
Viewer {
|
||||
info: ParticipantInfo {
|
||||
..Default::default()
|
||||
},
|
||||
role: Role::Reader,
|
||||
is_present: true,
|
||||
},
|
||||
];
|
||||
let participant_list = ParticipantList {
|
||||
sharer,
|
||||
viewers,
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
};
|
||||
|
||||
let presence_manager = app.add_model(|ctx| {
|
||||
PresenceManager::new_for_viewer(
|
||||
self_id.clone(),
|
||||
self_firebase_uid,
|
||||
participant_list.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Ensure participants are loaded before continuing.
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
presence_manager.read(&app, |presence_manager, _ctx| {
|
||||
let mut participant_colors = HashSet::new();
|
||||
let sharer = presence_manager.get_sharer().expect("should have sharer");
|
||||
participant_colors.insert(sharer.color);
|
||||
|
||||
// The viewers returned by presence manager should not include ourselves.
|
||||
let viewers = presence_manager.get_present_viewers().collect_vec();
|
||||
assert_eq!(viewers.len(), 3);
|
||||
for viewer in viewers {
|
||||
assert_ne!(viewer.info.id, self_id);
|
||||
participant_colors.insert(viewer.color);
|
||||
}
|
||||
|
||||
// The sharer and 3 other viewers should all use colors from the preset colors.
|
||||
let preset_colors = HashSet::from_iter(PRESET_COLORS[..4].iter().copied());
|
||||
assert!(participant_colors.eq(&preset_colors));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn block_list_for_test(max_block_index: usize) -> BlockList {
|
||||
let mut block_list = TestBlockListBuilder::new().build();
|
||||
|
||||
// Block 0 already exists as part of creating the blocklist
|
||||
for i in 1..max_block_index {
|
||||
block_list.command_finished(CommandFinishedValue {
|
||||
exit_code: ExitCode::from(0),
|
||||
next_block_id: i.to_string().into(),
|
||||
});
|
||||
block_list.precmd(Default::default());
|
||||
}
|
||||
block_list
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_block_index_for_avatar() {
|
||||
App::test((), |mut app| async move {
|
||||
// Initialize with a sharer who has blocks selected.
|
||||
let mut sharer = Sharer {
|
||||
info: ParticipantInfo {
|
||||
id: ParticipantId::new(),
|
||||
profile_data: ProfileData {
|
||||
..Default::default()
|
||||
},
|
||||
selection: Selection::Blocks {
|
||||
block_ids: vec![
|
||||
"1".to_string().into(),
|
||||
"4".to_string().into(),
|
||||
"2".to_string().into(),
|
||||
"10".to_string().into(),
|
||||
"9".to_string().into(),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
let viewers = Vec::new();
|
||||
let participant_list = ParticipantList {
|
||||
sharer: sharer.clone(),
|
||||
viewers: viewers.clone(),
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
};
|
||||
|
||||
let firebase_uid = UserUid::new("mock_firebase_uid");
|
||||
let presence_manager = app.add_model(|ctx| {
|
||||
PresenceManager::new_for_viewer(
|
||||
ParticipantId::new(),
|
||||
firebase_uid,
|
||||
participant_list.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Ensure participants are loaded before continuing.
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
let block_list = block_list_for_test(15);
|
||||
// Check the selected block index for sharer avatar
|
||||
presence_manager.read(&app, |presence_manager, _ctx| {
|
||||
let sharer = presence_manager.get_sharer().expect("should have sharer");
|
||||
let index = sharer
|
||||
.get_selected_block_index_for_avatar(&block_list)
|
||||
.expect("sharer should have selected block index for avatar");
|
||||
// 9 is the top of the last continuous block selection
|
||||
assert_eq!(index, 9.into())
|
||||
});
|
||||
|
||||
// Now try with just one block selected.
|
||||
sharer.info.selection = Selection::Blocks {
|
||||
block_ids: vec!["7".to_string().into()],
|
||||
};
|
||||
presence_manager
|
||||
.update(&mut app, |presence_manager, ctx| {
|
||||
presence_manager.update_participants(
|
||||
ParticipantList {
|
||||
sharer,
|
||||
viewers,
|
||||
present_viewers: Default::default(),
|
||||
absent_viewers: Default::default(),
|
||||
guests: Default::default(),
|
||||
pending_guests: Default::default(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let spawned_future = presence_manager
|
||||
.load_participants_imgs_future_handle
|
||||
.as_ref()
|
||||
.expect("should have future handle");
|
||||
ctx.await_spawned_future(spawned_future.future_id())
|
||||
})
|
||||
.await;
|
||||
presence_manager.read(&app, |presence_manager, _ctx| {
|
||||
let sharer = presence_manager.get_sharer().expect("should have sharer");
|
||||
let index = sharer
|
||||
.get_selected_block_index_for_avatar(&block_list)
|
||||
.expect("sharer should have selected block index for avatar");
|
||||
assert_eq!(index, 7.into())
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
ui_components::avatar::{Avatar, AvatarContent},
|
||||
};
|
||||
use warpui::{elements::CornerRadius, fonts::Weight};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildAnchor, Fill, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use super::presence_manager::{Participant, MUTED_AVATAR_BORDER_COLOR, MUTED_PARTICIPANT_COLOR};
|
||||
|
||||
pub fn shared_session_indicator_color(appearance: &Appearance) -> ColorU {
|
||||
appearance.theme().terminal_colors().normal.red.into()
|
||||
}
|
||||
|
||||
/// Diameter including the border
|
||||
pub const SHARED_SESSION_AVATAR_DIAMETER: f32 = 20.;
|
||||
pub const SHARED_SESSION_AVATAR_EXECUTOR_DIAMETER: f32 = 16.;
|
||||
|
||||
const SHARED_SESSION_DIAMETER_BORDER_WIDTH: f32 = 1.;
|
||||
|
||||
/// Shared helper function for rendering avatar in pane header and selected blocks.
|
||||
/// Actions on hover and click are handled separately.
|
||||
pub fn non_hoverable_participant_avatar(
|
||||
display_name: String,
|
||||
image_url: Option<String>,
|
||||
participant_color: ColorU,
|
||||
is_muted: bool,
|
||||
is_executor: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let background = if is_muted {
|
||||
MUTED_PARTICIPANT_COLOR
|
||||
} else {
|
||||
participant_color
|
||||
};
|
||||
let border_color = if is_muted {
|
||||
MUTED_AVATAR_BORDER_COLOR.into()
|
||||
} else if image_url.is_none() {
|
||||
appearance.theme().surface_2()
|
||||
} else {
|
||||
participant_color.into()
|
||||
};
|
||||
let diameter = if is_executor {
|
||||
SHARED_SESSION_AVATAR_EXECUTOR_DIAMETER
|
||||
} else {
|
||||
SHARED_SESSION_AVATAR_DIAMETER
|
||||
};
|
||||
let font = if is_executor { 10. } else { 12. };
|
||||
Avatar::new(
|
||||
image_url
|
||||
.map(|url| AvatarContent::Image {
|
||||
url,
|
||||
display_name: display_name.clone(),
|
||||
})
|
||||
.unwrap_or(AvatarContent::DisplayName(display_name)),
|
||||
UiComponentStyles {
|
||||
width: Some(diameter - 2. * SHARED_SESSION_DIAMETER_BORDER_WIDTH),
|
||||
height: Some(diameter - 2. * SHARED_SESSION_DIAMETER_BORDER_WIDTH),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
|
||||
border_width: Some(SHARED_SESSION_DIAMETER_BORDER_WIDTH),
|
||||
border_color: Some(border_color.into()),
|
||||
background: Some(background.into()),
|
||||
font_color: Some(ColorU::black()),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_weight: Some(Weight::Bold),
|
||||
font_size: Some(font),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Struct containing just fields from the [`Participant`] needed for rendering the avatar,
|
||||
/// to avoid unnecessary cloning of the other fields in the participant.
|
||||
#[derive(Clone)]
|
||||
pub struct ParticipantAvatarParams {
|
||||
pub display_name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub participant_color: ColorU,
|
||||
pub is_muted: bool,
|
||||
pub tooltip_parent_anchor: ParentAnchor,
|
||||
pub tooltip_child_anchor: ChildAnchor,
|
||||
}
|
||||
|
||||
impl ParticipantAvatarParams {
|
||||
pub fn new(participant: &Participant, is_self_reconnecting: bool) -> Self {
|
||||
Self {
|
||||
display_name: participant.info.profile_data.display_name.clone(),
|
||||
image_url: participant.info.profile_data.photo_url.clone(),
|
||||
participant_color: participant.color.to_owned(),
|
||||
is_muted: is_self_reconnecting,
|
||||
tooltip_parent_anchor: ParentAnchor::TopRight,
|
||||
tooltip_child_anchor: ChildAnchor::BottomRight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to render participant avatar and handle hover in selected blocks.
|
||||
pub fn participant_avatar_for_selected_block(
|
||||
params: ParticipantAvatarParams,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let avatar = non_hoverable_participant_avatar(
|
||||
params.display_name.clone(),
|
||||
params.image_url,
|
||||
params.participant_color,
|
||||
params.is_muted,
|
||||
false,
|
||||
app,
|
||||
);
|
||||
|
||||
Hoverable::new(mouse_state_handle, |state| {
|
||||
let mut stack = Stack::new().with_child(avatar);
|
||||
if state.is_hovered() {
|
||||
let tooltip_background = appearance.theme().tooltip_background();
|
||||
let tool_tip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(params.display_name)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
background: Some(Fill::Solid(tooltip_background)),
|
||||
font_color: Some(appearance.theme().background().into_solid()),
|
||||
..Default::default()
|
||||
});
|
||||
stack.add_positioned_overlay_child(
|
||||
tool_tip.build().finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
params.tooltip_parent_anchor,
|
||||
params.tooltip_child_anchor,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::AIAgentExchange;
|
||||
use crate::ai::agent::MessageId;
|
||||
use api::client_action as api_client_action;
|
||||
use api::response_event as api_response_event;
|
||||
use api::response_event::stream_finished as stream_finished_event;
|
||||
use std::collections::HashMap;
|
||||
use warp_multi_agent_api::{self as api, ResponseEvent};
|
||||
|
||||
// Reconstructs all response events from conversations for use in session sharing.
|
||||
// These messages are used to replay conversations as if they were happening live.
|
||||
pub fn reconstruct_response_events_from_conversations(
|
||||
conversations: &[AIConversation],
|
||||
) -> Vec<ResponseEvent> {
|
||||
let mut events = vec![];
|
||||
|
||||
// Build a map of message_id -> (task_id, message, conversation) for quick lookup
|
||||
let mut message_map: HashMap<MessageId, (&TaskId, &api::Message, &AIConversation)> =
|
||||
HashMap::new();
|
||||
for conversation in conversations {
|
||||
for task in conversation.all_tasks() {
|
||||
for message in task.messages() {
|
||||
message_map.insert(
|
||||
MessageId::new(message.id.clone()),
|
||||
(task.id(), message, conversation),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all exchanges from all conversations and sort by start time
|
||||
let mut all_exchanges: Vec<(&AIConversation, &AIAgentExchange)> = conversations
|
||||
.iter()
|
||||
.flat_map(|conv| {
|
||||
conv.all_exchanges()
|
||||
.into_iter()
|
||||
.map(move |exchange| (conv, exchange))
|
||||
})
|
||||
.collect();
|
||||
all_exchanges.sort_by_key(|(_, exchange)| exchange.start_time);
|
||||
|
||||
// Track which conversations have had their tasks created.
|
||||
// We need to send CreateTask on the first exchange to upgrade local task IDs
|
||||
// to server task IDs (required for AddMessagesToTask to find the correct task).
|
||||
let mut initialized_conversations = std::collections::HashSet::new();
|
||||
|
||||
// For each exchange (in chronological order), emit events
|
||||
for (conversation, exchange) in all_exchanges {
|
||||
// Collect messages for this exchange in chronological order
|
||||
let mut exchange_messages: Vec<(&TaskId, &api::Message)> = exchange
|
||||
.added_message_ids
|
||||
.iter()
|
||||
.filter_map(|msg_id| {
|
||||
message_map
|
||||
.get(msg_id)
|
||||
.map(|(task_id, msg, _)| (*task_id, *msg))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if exchange_messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by timestamp to ensure chronological order
|
||||
exchange_messages.sort_by_key(|(_, message)| {
|
||||
message.timestamp.as_ref().map(|ts| (ts.seconds, ts.nanos))
|
||||
});
|
||||
|
||||
// Use the server conversation token if it's available.
|
||||
// Otherwise, fall back to the id that this conversation was forked from.
|
||||
// This ensures viewers can properly group historical exchanges together.
|
||||
let token = conversation
|
||||
.server_conversation_token()
|
||||
.or_else(|| conversation.forked_from_server_conversation_token())
|
||||
.map(|t| t.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
let request_id = exchange_messages
|
||||
.first()
|
||||
.map(|(_, msg)| msg.request_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Start this exchange
|
||||
events.push(ResponseEvent {
|
||||
r#type: Some(api_response_event::Type::Init(
|
||||
api_response_event::StreamInit {
|
||||
request_id,
|
||||
conversation_id: token.clone(),
|
||||
// Shared session replays don't need a run_id; the empty
|
||||
// string is filtered to None by initialize_output_for_response_stream.
|
||||
run_id: String::new(),
|
||||
},
|
||||
)),
|
||||
});
|
||||
|
||||
// On the first exchange of each conversation, send CreateTask events to upgrade
|
||||
// local task IDs to server task IDs. We construct a task with empty messages
|
||||
// because the messages will be added via AddMessagesToTask below - including them
|
||||
// in CreateTask would cause duplicate content in the exchange.
|
||||
let conversation_id = conversation.id();
|
||||
let is_first_exchange = !initialized_conversations.contains(&conversation_id);
|
||||
if is_first_exchange {
|
||||
initialized_conversations.insert(conversation_id);
|
||||
for task in conversation.all_tasks() {
|
||||
if let Some(task_source) = task.source() {
|
||||
let task_without_messages = api::Task {
|
||||
id: task_source.id.clone(),
|
||||
description: task_source.description.clone(),
|
||||
dependencies: task_source.dependencies.clone(),
|
||||
messages: vec![], // Empty - messages added via AddMessagesToTask
|
||||
summary: task_source.summary.clone(),
|
||||
server_data: task_source.server_data.clone(),
|
||||
};
|
||||
events.push(wrap_action_in_event(api_client_action::Action::CreateTask(
|
||||
api_client_action::CreateTask {
|
||||
task: Some(task_without_messages),
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send all messages for this exchange
|
||||
for (task_id, message) in exchange_messages {
|
||||
events.push(wrap_action_in_event(
|
||||
api_client_action::Action::AddMessagesToTask(
|
||||
api_client_action::AddMessagesToTask {
|
||||
task_id: task_id.to_string(),
|
||||
messages: vec![message.clone()],
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Finish this exchange
|
||||
events.push(create_finished_event_from_conversation(conversation));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// Wrap a ClientAction in a ResponseEvent.
|
||||
fn wrap_action_in_event(action: api_client_action::Action) -> ResponseEvent {
|
||||
ResponseEvent {
|
||||
r#type: Some(api_response_event::Type::ClientActions(
|
||||
api_response_event::ClientActions {
|
||||
actions: vec![api::ClientAction {
|
||||
action: Some(action),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a StreamFinished event from a conversation.
|
||||
fn create_finished_event_from_conversation(conversation: &AIConversation) -> ResponseEvent {
|
||||
// Build conversation usage metadata from the conversation's metadata
|
||||
let usage_metadata = Some(
|
||||
api_response_event::stream_finished::ConversationUsageMetadata {
|
||||
context_window_usage: conversation.context_window_usage(),
|
||||
credits_spent: conversation.credits_spent(),
|
||||
summarized: conversation.was_summarized(),
|
||||
#[allow(deprecated)]
|
||||
token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.map(|u| u.to_proto_combined())
|
||||
.collect(),
|
||||
tool_usage_metadata: Some(conversation.tool_usage_metadata().into()),
|
||||
warp_token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_warp_usage())
|
||||
.collect(),
|
||||
byok_token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_byok_usage())
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
|
||||
ResponseEvent {
|
||||
r#type: Some(api_response_event::Type::Finished(
|
||||
api_response_event::StreamFinished {
|
||||
reason: Some(stream_finished_event::Reason::Done(
|
||||
stream_finished_event::Done {},
|
||||
)),
|
||||
conversation_usage_metadata: usage_metadata,
|
||||
token_usage: vec![],
|
||||
should_refresh_model_config: false,
|
||||
request_cost: None,
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
use warpui::elements::Empty;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::{
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
AppContext, Element, Entity, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::modal::Modal;
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
use crate::terminal::shared_session::render_util::ParticipantAvatarParams;
|
||||
|
||||
mod sharer_grant_body;
|
||||
mod sharer_response_body;
|
||||
mod viewer_request_body;
|
||||
use sharer_grant_body::{SharerGrantBody, SharerGrantBodyEvent};
|
||||
use sharer_response_body::{SharerResponseBody, SharerResponseBodyEvent};
|
||||
use viewer_request_body::{ViewerRequestBody, ViewerRequestBodyEvent};
|
||||
|
||||
pub const MODAL_WIDTH: f32 = 400.;
|
||||
pub const MODAL_PADDING: f32 = 24.;
|
||||
pub const BODY_PADDING: f32 = 8.;
|
||||
pub const HEADER_FONT_SIZE: f32 = 16.;
|
||||
pub const TEXT_FONT_SIZE: f32 = 14.;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoleChangeOpenSource {
|
||||
ViewerRequest {
|
||||
role: Role,
|
||||
},
|
||||
SharerResponse {
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
},
|
||||
SharerGrant {
|
||||
participant_id: ParticipantId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RoleChangeCloseSource {
|
||||
ViewerRequest,
|
||||
SharerResponse,
|
||||
SharerGrant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoleChangeModalEvent {
|
||||
// Viewer request event
|
||||
CancelRequest {
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
role_request_id: RoleRequestId,
|
||||
},
|
||||
// Sharer response events
|
||||
ApproveRequest {
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
},
|
||||
DenyRequest {
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
},
|
||||
Close {
|
||||
source: RoleChangeCloseSource,
|
||||
},
|
||||
// Sharer grant events
|
||||
CancelGrant,
|
||||
GrantRole {
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
participant_id: ParticipantId,
|
||||
dont_show_again: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct RoleChangeModal {
|
||||
terminal_pane_id: Option<TerminalPaneId>,
|
||||
role_request_id: Option<RoleRequestId>,
|
||||
participant_id: Option<ParticipantId>,
|
||||
|
||||
is_viewer_request_modal_open: bool,
|
||||
viewer_request_modal: ViewHandle<Modal<ViewerRequestBody>>,
|
||||
|
||||
is_sharer_response_modal_open: bool,
|
||||
sharer_response_modal: ViewHandle<Modal<SharerResponseBody>>,
|
||||
|
||||
is_sharer_grant_modal_open: bool,
|
||||
sharer_grant_modal: ViewHandle<Modal<SharerGrantBody>>,
|
||||
}
|
||||
|
||||
impl Entity for RoleChangeModal {
|
||||
type Event = RoleChangeModalEvent;
|
||||
}
|
||||
|
||||
impl RoleChangeModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let modal_style = UiComponentStyles {
|
||||
width: Some(MODAL_WIDTH),
|
||||
..Default::default()
|
||||
};
|
||||
let body_style = UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: MODAL_PADDING,
|
||||
bottom: MODAL_PADDING,
|
||||
left: MODAL_PADDING,
|
||||
right: MODAL_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let viewer_request_body = ctx.add_typed_action_view(|_| ViewerRequestBody::new());
|
||||
ctx.subscribe_to_view(&viewer_request_body, |me, _, event, ctx| {
|
||||
me.handle_viewer_event(event, ctx);
|
||||
});
|
||||
|
||||
let viewer_request_modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(None, viewer_request_body, ctx)
|
||||
.with_modal_style(modal_style)
|
||||
.with_body_style(body_style)
|
||||
.with_background_opacity(100)
|
||||
.close_modal_button_disabled()
|
||||
});
|
||||
|
||||
let sharer_response_body = ctx.add_typed_action_view(|_| SharerResponseBody::new());
|
||||
ctx.subscribe_to_view(&sharer_response_body, |me, _, event, ctx| {
|
||||
me.handle_sharer_response_event(event, ctx);
|
||||
});
|
||||
|
||||
let sharer_response_modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(None, sharer_response_body, ctx)
|
||||
.with_modal_style(modal_style)
|
||||
.with_body_style(body_style)
|
||||
.with_background_opacity(100)
|
||||
.close_modal_button_disabled()
|
||||
});
|
||||
|
||||
let sharer_grant_body = ctx.add_typed_action_view(|_| SharerGrantBody::new());
|
||||
ctx.subscribe_to_view(&sharer_grant_body, |me, _, event, ctx| {
|
||||
me.handle_sharer_grant_event(event, ctx);
|
||||
});
|
||||
|
||||
let sharer_grant_modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(None, sharer_grant_body, ctx)
|
||||
.with_modal_style(modal_style)
|
||||
.with_body_style(body_style)
|
||||
.with_background_opacity(100)
|
||||
.close_modal_button_disabled()
|
||||
});
|
||||
|
||||
Self {
|
||||
terminal_pane_id: None,
|
||||
role_request_id: None,
|
||||
participant_id: None,
|
||||
is_viewer_request_modal_open: false,
|
||||
viewer_request_modal,
|
||||
is_sharer_response_modal_open: false,
|
||||
sharer_response_modal,
|
||||
is_sharer_grant_modal_open: false,
|
||||
sharer_grant_modal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_role_request_id(&mut self, role_request_id: RoleRequestId) {
|
||||
self.role_request_id = Some(role_request_id);
|
||||
}
|
||||
|
||||
/// Opens viewer's role request modal which awaits a sharer's response.
|
||||
/// Viewer can cancel their request through this modal.
|
||||
pub fn open_for_viewer_request(
|
||||
&mut self,
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
display_name: String,
|
||||
role: Role,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.terminal_pane_id = Some(terminal_pane_id);
|
||||
self.is_viewer_request_modal_open = true;
|
||||
|
||||
self.viewer_request_modal.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |modal, ctx| {
|
||||
modal.open(display_name, role, ctx);
|
||||
});
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Opens sharer's role response modal.
|
||||
/// Sharer can approve/deny role requests through this modal.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_for_sharer_response(
|
||||
&mut self,
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
participant_id: ParticipantId,
|
||||
firebase_uid: String,
|
||||
role_request_id: RoleRequestId,
|
||||
params: ParticipantAvatarParams,
|
||||
role: Role,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.terminal_pane_id = Some(terminal_pane_id);
|
||||
self.role_request_id = Some(role_request_id.clone());
|
||||
self.is_sharer_response_modal_open = true;
|
||||
|
||||
self.sharer_response_modal.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |modal, ctx| {
|
||||
modal.add_role_request(
|
||||
participant_id,
|
||||
firebase_uid,
|
||||
role_request_id.clone(),
|
||||
role,
|
||||
params,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Opens sharer's role grant confirmation modal.
|
||||
/// Sharer can cancel/continue the role grant through this modal.
|
||||
pub fn open_for_sharer_grant(
|
||||
&mut self,
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
participant_id: ParticipantId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.terminal_pane_id = Some(terminal_pane_id);
|
||||
self.participant_id = Some(participant_id);
|
||||
self.is_sharer_grant_modal_open = true;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn all_child_modals_are_closed(&self) -> bool {
|
||||
!self.is_viewer_request_modal_open
|
||||
&& !self.is_sharer_response_modal_open
|
||||
&& !self.is_sharer_grant_modal_open
|
||||
}
|
||||
|
||||
/// Closes viewer's role request modal.
|
||||
pub fn close_for_viewer_request(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_viewer_request_modal_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Closes sharer's role response modal.
|
||||
pub fn close_for_sharer_response(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_sharer_response_modal_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Closes sharer's role grant modal.
|
||||
/// Should only be closed when there are no pending role requests.
|
||||
pub fn close_for_sharer_grant(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_sharer_grant_modal_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Cancels the role request identified by the role request id.
|
||||
/// Can only cancel our own role request as a viewer.
|
||||
pub fn cancel_request(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Ensure the right modal is open before cancelling
|
||||
if !self.is_viewer_request_modal_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(terminal_pane_id) = self.terminal_pane_id else {
|
||||
log::warn!("Tried to close role request modal when no terminal pane ID was present");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(role_request_id) = self.role_request_id.as_ref() {
|
||||
ctx.emit(RoleChangeModalEvent::CancelRequest {
|
||||
terminal_pane_id,
|
||||
role_request_id: role_request_id.clone(),
|
||||
})
|
||||
} else {
|
||||
log::warn!("Tried to cancel role request when no role request ID was present");
|
||||
// If no role request ID is present, we should still close the modal.
|
||||
ctx.emit(RoleChangeModalEvent::Close {
|
||||
source: RoleChangeCloseSource::ViewerRequest,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/// Removes a role request given the id, and is called when a sharer approves/denies one.
|
||||
/// Only the sharer can remove the role requests for their shared session.
|
||||
pub fn remove_role_request(
|
||||
&mut self,
|
||||
role_request_id: RoleRequestId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Ensure the right modal is open before removing
|
||||
if !self.is_sharer_response_modal_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.sharer_response_modal.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |modal, ctx| {
|
||||
modal.remove_role_request(role_request_id, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_viewer_event(&mut self, event: &ViewerRequestBodyEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
ViewerRequestBodyEvent::Cancel => self.cancel_request(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sharer_grant_event(
|
||||
&mut self,
|
||||
event: &SharerGrantBodyEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SharerGrantBodyEvent::Cancel => {
|
||||
if self.terminal_pane_id.is_none() {
|
||||
log::warn!("Tried to cancel role grant when no terminal pane ID was present");
|
||||
return;
|
||||
};
|
||||
ctx.emit(RoleChangeModalEvent::CancelGrant)
|
||||
}
|
||||
SharerGrantBodyEvent::GrantRole { dont_show_again } => {
|
||||
let Some(terminal_pane_id) = self.terminal_pane_id else {
|
||||
log::warn!("Tried to grant role when no terminal pane ID was present");
|
||||
return;
|
||||
};
|
||||
let Some(participant_id) = self.participant_id.clone() else {
|
||||
log::warn!("Tried to grant role without participant ID");
|
||||
return;
|
||||
};
|
||||
ctx.emit(RoleChangeModalEvent::GrantRole {
|
||||
terminal_pane_id,
|
||||
participant_id,
|
||||
dont_show_again: *dont_show_again,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sharer_response_event(
|
||||
&mut self,
|
||||
event: &SharerResponseBodyEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SharerResponseBodyEvent::Approve {
|
||||
participant_id,
|
||||
role_request_id,
|
||||
role,
|
||||
} => {
|
||||
let Some(terminal_pane_id) = self.terminal_pane_id else {
|
||||
log::warn!(
|
||||
"Tried to close role request modal when no terminal pane ID was present"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.emit(RoleChangeModalEvent::ApproveRequest {
|
||||
terminal_pane_id,
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: role_request_id.clone(),
|
||||
role: *role,
|
||||
});
|
||||
}
|
||||
SharerResponseBodyEvent::Deny {
|
||||
participant_id,
|
||||
role_request_id,
|
||||
} => {
|
||||
let Some(terminal_pane_id) = self.terminal_pane_id else {
|
||||
log::warn!(
|
||||
"Tried to close role request modal when no terminal pane ID was present"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.emit(RoleChangeModalEvent::DenyRequest {
|
||||
terminal_pane_id,
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: role_request_id.clone(),
|
||||
});
|
||||
}
|
||||
SharerResponseBodyEvent::Close => {
|
||||
if self.terminal_pane_id.is_none() {
|
||||
log::warn!(
|
||||
"Tried to close role request modal when no terminal pane ID was present"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.emit(RoleChangeModalEvent::Close {
|
||||
source: RoleChangeCloseSource::SharerResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for RoleChangeModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"SharedSessionRoleChangeModal"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
if self.is_sharer_grant_modal_open {
|
||||
ChildView::new(&self.sharer_grant_modal).finish()
|
||||
} else if self.is_sharer_response_modal_open {
|
||||
ChildView::new(&self.sharer_response_modal).finish()
|
||||
} else if self.is_viewer_request_modal_open {
|
||||
ChildView::new(&self.viewer_request_modal).finish()
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use warpui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::text::Span;
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
use super::{MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
const BUTTON_HEIGHT: f32 = 40.;
|
||||
const BUTTON_WIDTH: f32 = 172.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SharerGrantBodyAction {
|
||||
Cancel,
|
||||
GrantRole { dont_show_again: bool },
|
||||
ToggleDontShowAgain,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SharerGrantBodyEvent {
|
||||
Cancel,
|
||||
GrantRole { dont_show_again: bool },
|
||||
}
|
||||
|
||||
pub struct SharerGrantBody {
|
||||
cancel_button_mouse_state: MouseStateHandle,
|
||||
approve_button_mouse_state: MouseStateHandle,
|
||||
dont_show_again_mouse_state: MouseStateHandle,
|
||||
dont_show_again: bool,
|
||||
}
|
||||
|
||||
impl SharerGrantBody {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancel_button_mouse_state: Default::default(),
|
||||
approve_button_mouse_state: Default::default(),
|
||||
dont_show_again_mouse_state: Default::default(),
|
||||
dont_show_again: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_button_row(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let cancel_button = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.cancel_button_mouse_state.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(BUTTON_HEIGHT),
|
||||
width: Some(BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label(String::from("Cancel"))
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(SharerGrantBodyAction::Cancel))
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(8.)
|
||||
.finish();
|
||||
|
||||
let dont_show_again = self.dont_show_again;
|
||||
let approve_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.approve_button_mouse_state.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(BUTTON_HEIGHT),
|
||||
width: Some(BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label(String::from("Make Editor"))
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SharerGrantBodyAction::GrantRole { dont_show_again })
|
||||
})
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_child(cancel_button)
|
||||
.with_child(approve_button)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SharerGrantBody {
|
||||
type Event = SharerGrantBodyEvent;
|
||||
}
|
||||
|
||||
impl View for SharerGrantBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"SharerGrantBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let button_row = self.render_button_row(appearance);
|
||||
|
||||
let text1 = "This grants the ability to execute commands on your";
|
||||
let text2 = "behalf. Use with caution.";
|
||||
let text_body = Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Text::new_inline(text1, appearance.ui_font_family(), TEXT_FONT_SIZE)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.with_style(Properties::default().weight(Weight::Normal))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new_inline(text2, appearance.ui_font_family(), TEXT_FONT_SIZE)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.with_style(Properties::default().weight(Weight::Normal))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(16.)
|
||||
.finish();
|
||||
|
||||
let dont_show_again_checkbox = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.checkbox(
|
||||
self.dont_show_again_mouse_state.clone(),
|
||||
Some(TEXT_FONT_SIZE),
|
||||
)
|
||||
.with_label(Span::new("Don't show again.", Default::default()))
|
||||
.check(self.dont_show_again)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SharerGrantBodyAction::ToggleDontShowAgain)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(MODAL_PADDING)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(text_body)
|
||||
.with_child(dont_show_again_checkbox)
|
||||
.with_child(button_row)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SharerGrantBody {
|
||||
type Action = SharerGrantBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SharerGrantBodyAction::Cancel => {
|
||||
ctx.emit(SharerGrantBodyEvent::Cancel);
|
||||
self.dont_show_again = false;
|
||||
}
|
||||
SharerGrantBodyAction::GrantRole { dont_show_again } => {
|
||||
ctx.emit(SharerGrantBodyEvent::GrantRole {
|
||||
dont_show_again: *dont_show_again,
|
||||
});
|
||||
}
|
||||
SharerGrantBodyAction::ToggleDontShowAgain => {
|
||||
self.dont_show_again = !self.dont_show_again;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::terminal::shared_session::render_util::{
|
||||
non_hoverable_participant_avatar, ParticipantAvatarParams,
|
||||
};
|
||||
use crate::{appearance::Appearance, ui_components::blended_colors};
|
||||
use session_sharing_protocol::common::{ParticipantId, Role, RoleRequestId};
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::Properties;
|
||||
use warpui::{
|
||||
elements::CrossAxisAlignment,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
|
||||
pub const BUTTON_HEIGHT: f32 = 32.;
|
||||
pub const BUTTON_WIDTH: f32 = 75.;
|
||||
pub const BUTTON_FONT_SIZE: f32 = 12.;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MouseStateHandles {
|
||||
approve_button: MouseStateHandle,
|
||||
deny_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
// Struct that contains fields needed to
|
||||
// render a role request.
|
||||
#[derive(Clone)]
|
||||
struct RoleRequestParams {
|
||||
participant_id: ParticipantId,
|
||||
firebase_uid: String,
|
||||
role: Role,
|
||||
avatar: ParticipantAvatarParams,
|
||||
button_mouse_states: MouseStateHandles,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SharerResponseBodyAction {
|
||||
Approve {
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
},
|
||||
Deny {
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum SharerResponseBodyEvent {
|
||||
Approve {
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
},
|
||||
Deny {
|
||||
participant_id: ParticipantId,
|
||||
role_request_id: RoleRequestId,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct SharerResponseBody {
|
||||
role_requests: HashMap<RoleRequestId, RoleRequestParams>,
|
||||
}
|
||||
|
||||
impl SharerResponseBody {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
role_requests: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_role_request(
|
||||
&mut self,
|
||||
participant_id: ParticipantId,
|
||||
firebase_uid: String,
|
||||
role_request_id: RoleRequestId,
|
||||
role: Role,
|
||||
params: ParticipantAvatarParams,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let role_request = RoleRequestParams {
|
||||
participant_id: participant_id.clone(),
|
||||
firebase_uid: firebase_uid.clone(),
|
||||
role,
|
||||
avatar: params,
|
||||
button_mouse_states: MouseStateHandles {
|
||||
approve_button: Default::default(),
|
||||
deny_button: Default::default(),
|
||||
},
|
||||
};
|
||||
|
||||
// Ensure there exists only one request per participant
|
||||
// by removing the previous request (if it exists)
|
||||
// If ACLs are enabled, we make sure there is only one request per user.
|
||||
if let Some(request_id) = self.role_requests.iter().find_map(|(request_id, params)| {
|
||||
let is_duplicate = if FeatureFlag::SessionSharingAcls.is_enabled() {
|
||||
params.firebase_uid == firebase_uid
|
||||
} else {
|
||||
params.participant_id == participant_id
|
||||
};
|
||||
is_duplicate.then_some(request_id.clone())
|
||||
}) {
|
||||
self.role_requests.remove(&request_id);
|
||||
}
|
||||
|
||||
self.role_requests.insert(role_request_id, role_request);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn remove_role_request(
|
||||
&mut self,
|
||||
role_request_id: RoleRequestId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.role_requests.remove(&role_request_id);
|
||||
|
||||
if self.role_requests.is_empty() {
|
||||
ctx.emit(SharerResponseBodyEvent::Close)
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_button_row(
|
||||
&self,
|
||||
role_request_id: RoleRequestId,
|
||||
role_request_params: RoleRequestParams,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let participant_id = role_request_params.participant_id.clone();
|
||||
let request_id = role_request_id.clone();
|
||||
let deny_button = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
role_request_params.button_mouse_states.deny_button,
|
||||
)
|
||||
.with_centered_text_label(String::from("Deny"))
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(BUTTON_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(BUTTON_HEIGHT),
|
||||
width: Some(BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SharerResponseBodyAction::Deny {
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: request_id.clone(),
|
||||
})
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(BODY_PADDING)
|
||||
.finish();
|
||||
|
||||
let participant_id = role_request_params.participant_id.clone();
|
||||
let request_id = role_request_id.clone();
|
||||
let role = role_request_params.role;
|
||||
let approve_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
role_request_params.button_mouse_states.approve_button,
|
||||
)
|
||||
.with_centered_text_label(String::from("Approve"))
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(BUTTON_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(BUTTON_HEIGHT),
|
||||
width: Some(BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SharerResponseBodyAction::Approve {
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: request_id.clone(),
|
||||
role,
|
||||
})
|
||||
})
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_child(deny_button)
|
||||
.with_child(approve_button)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_role_request(
|
||||
&self,
|
||||
role_request_id: RoleRequestId,
|
||||
role_request_params: RoleRequestParams,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let button_row =
|
||||
self.render_button_row(role_request_id, role_request_params.clone(), appearance);
|
||||
let avatar_params = role_request_params.avatar;
|
||||
|
||||
let avatar = non_hoverable_participant_avatar(
|
||||
avatar_params.display_name.clone(),
|
||||
avatar_params.image_url,
|
||||
avatar_params.participant_color,
|
||||
avatar_params.is_muted,
|
||||
false,
|
||||
app,
|
||||
);
|
||||
|
||||
let participant = ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(avatar)
|
||||
.with_padding_right(BODY_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
avatar_params.display_name,
|
||||
appearance.ui_font_family(),
|
||||
TEXT_FONT_SIZE,
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(220.)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_child(participant)
|
||||
.with_child(button_row)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SharerResponseBody {
|
||||
type Event = SharerResponseBodyEvent;
|
||||
}
|
||||
|
||||
impl View for SharerResponseBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"SharerResponseBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let header = "Edit Requests";
|
||||
let text1 = "This grants the ability to execute commands on your";
|
||||
let text2 = "behalf. Use with caution.";
|
||||
|
||||
let text_body = Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new_inline(header, appearance.ui_font_family(), HEADER_FONT_SIZE)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(BODY_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Text::new(text1, appearance.ui_font_family(), TEXT_FONT_SIZE)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new(text2, appearance.ui_font_family(), TEXT_FONT_SIZE)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(MODAL_PADDING)
|
||||
.finish();
|
||||
|
||||
let mut role_requests = Flex::column();
|
||||
for (i, (id, params)) in self.role_requests.iter().enumerate() {
|
||||
let mut role_request =
|
||||
self.render_role_request(id.clone(), params.clone(), appearance, app);
|
||||
// Don't add extra padding to the last element
|
||||
if i != self.role_requests.len() - 1 {
|
||||
role_request = Container::new(role_request)
|
||||
.with_padding_bottom(BODY_PADDING)
|
||||
.finish();
|
||||
}
|
||||
role_requests.add_child(role_request);
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_child(text_body)
|
||||
.with_child(
|
||||
role_requests
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
||||
.finish(),
|
||||
)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SharerResponseBody {
|
||||
type Action = SharerResponseBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SharerResponseBodyAction::Approve {
|
||||
participant_id,
|
||||
role_request_id,
|
||||
role,
|
||||
} => {
|
||||
ctx.emit(SharerResponseBodyEvent::Approve {
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: role_request_id.clone(),
|
||||
role: *role,
|
||||
});
|
||||
self.remove_role_request(role_request_id.clone(), ctx);
|
||||
}
|
||||
SharerResponseBodyAction::Deny {
|
||||
participant_id,
|
||||
role_request_id,
|
||||
} => {
|
||||
ctx.emit(SharerResponseBodyEvent::Deny {
|
||||
participant_id: participant_id.clone(),
|
||||
role_request_id: role_request_id.clone(),
|
||||
});
|
||||
self.remove_role_request(role_request_id.clone(), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{appearance::Appearance, ui_components::blended_colors};
|
||||
use session_sharing_protocol::common::Role;
|
||||
use warpui::elements::{Container, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text};
|
||||
use warpui::{
|
||||
elements::CrossAxisAlignment,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{BODY_PADDING, HEADER_FONT_SIZE, MODAL_PADDING, TEXT_FONT_SIZE};
|
||||
|
||||
pub const BUTTON_HEIGHT: f32 = 40.;
|
||||
pub const BUTTON_WIDTH: f32 = 352.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ViewerRequestBodyAction {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub enum ViewerRequestBodyEvent {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct ViewerRequestBody {
|
||||
role: Role,
|
||||
display_name: String,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ViewerRequestBody {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
role: Default::default(),
|
||||
display_name: Default::default(),
|
||||
mouse_state_handle: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn role_label(&self) -> &str {
|
||||
match self.role {
|
||||
Role::Executor => "edit",
|
||||
_ => "view",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self, display_name: String, role: Role, ctx: &mut ViewContext<Self>) {
|
||||
self.role = role;
|
||||
self.display_name = display_name;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ViewerRequestBody {
|
||||
type Event = ViewerRequestBodyEvent;
|
||||
}
|
||||
|
||||
impl View for ViewerRequestBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"ViewerRequestBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let header = format!("You have requested {} mode", self.role_label());
|
||||
let text = format!("Waiting for {}...", self.display_name);
|
||||
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Outlined, self.mouse_state_handle.clone())
|
||||
.with_centered_text_label(String::from("Cancel request"))
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(BUTTON_HEIGHT),
|
||||
width: Some(BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ViewerRequestBodyAction::Cancel))
|
||||
.finish();
|
||||
|
||||
let text_body = Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new_inline(header, appearance.ui_font_family(), HEADER_FONT_SIZE)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(BODY_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new_inline(text, appearance.ui_font_family(), TEXT_FONT_SIZE)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(MODAL_PADDING)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(text_body)
|
||||
.with_child(cancel_button)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ViewerRequestBody {
|
||||
type Action = ViewerRequestBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ViewerRequestBodyAction::Cancel => ctx.emit(ViewerRequestBodyEvent::Cancel),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use crate::terminal::model::{blocks::BlockList, index::Point, terminal_model::WithinBlock};
|
||||
use session_sharing_protocol::common::BlockPoint;
|
||||
|
||||
impl WithinBlock<Point> {
|
||||
/// Converts an un-transformed block point
|
||||
/// to a transformed [`WithinBlock<Point>`].
|
||||
///
|
||||
/// We make the following transformations:
|
||||
/// 1. ensure the point fits our grid,
|
||||
/// 2. turn the grid-compatible point into a displayed point so that it respects filters
|
||||
pub fn from_session_sharing_block_point(
|
||||
point: BlockPoint,
|
||||
block_list: &BlockList,
|
||||
) -> Option<Self> {
|
||||
let block_index = block_list.block_index_for_id(&point.block_id.to_string().into())?;
|
||||
let grid_type = point.grid_type.into();
|
||||
let grid = block_list
|
||||
.block_at(block_index)?
|
||||
.grid_of_type(grid_type)?
|
||||
.grid_handler();
|
||||
|
||||
let inner = grid.compatible_point(point.point.into());
|
||||
let inner = if !grid.is_displayed_row(inner.row) {
|
||||
return None;
|
||||
} else {
|
||||
grid.maybe_translate_point_from_original_to_displayed(inner)
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
block_index,
|
||||
grid: grid_type,
|
||||
inner,
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a transformed [`WithinBlock<Point>`]
|
||||
/// to a view-agnostic [`BlockPoint`].
|
||||
///
|
||||
/// This should be the inverse of [`WithinBlock<Point>::from_session_sharing_block_point`].
|
||||
pub fn to_session_sharing_block_point(self, block_list: &BlockList) -> Option<BlockPoint> {
|
||||
let block_id = block_list.block_at(self.block_index)?.id();
|
||||
let grid = block_list.grid_at_location(&self).grid_handler();
|
||||
|
||||
let point = grid.maybe_translate_point_from_displayed_to_original(self.inner);
|
||||
let point = grid.grid_agnostic_point(point);
|
||||
|
||||
Some(BlockPoint {
|
||||
block_id: block_id.to_string().into(),
|
||||
point: point.into(),
|
||||
grid_type: self.grid.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selections_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,459 @@
|
||||
use warp_core::semantic_selection::SemanticSelection;
|
||||
use warpui::App;
|
||||
|
||||
use crate::terminal::{
|
||||
block_filter::BlockFilterQuery,
|
||||
event_listener::ChannelEventListener,
|
||||
model::{
|
||||
block::SerializedBlock,
|
||||
blocks::BlockListPoint,
|
||||
index::{Point, Side},
|
||||
terminal_model::WithinBlock,
|
||||
},
|
||||
shared_session::tests::terminal_model_for_viewer,
|
||||
GridType, SizeInfo, SizeUpdate, SizeUpdateReason, TerminalModel,
|
||||
};
|
||||
use warpui::text::SelectionType;
|
||||
|
||||
/// Creates a [`SelectionType::Simple`], left-to-right text selection
|
||||
/// from `start` to `end` in the `model`'s blocklist.
|
||||
fn create_simple_text_selection(
|
||||
model: &mut TerminalModel,
|
||||
start: WithinBlock<Point>,
|
||||
end: WithinBlock<Point>,
|
||||
) {
|
||||
let start_block_point = BlockListPoint::from_within_block_point(&start, model.block_list());
|
||||
let end_block_point = BlockListPoint::from_within_block_point(&end, model.block_list());
|
||||
model
|
||||
.block_list_mut()
|
||||
.start_selection(start_block_point, SelectionType::Simple, Side::Left);
|
||||
model
|
||||
.block_list_mut()
|
||||
.update_selection(end_block_point, Side::Right);
|
||||
}
|
||||
|
||||
fn create_sharer_and_viewer_models_with_same_block(
|
||||
input: &str,
|
||||
output: &str,
|
||||
) -> (TerminalModel, TerminalModel) {
|
||||
let mut sharer_model = TerminalModel::mock(None, None);
|
||||
sharer_model.simulate_block(input, output);
|
||||
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let mut viewer_model = terminal_model_for_viewer(channel_event_proxy);
|
||||
let block = sharer_model.block_list().last_non_hidden_block().unwrap();
|
||||
let serialized_block = SerializedBlock::from(block);
|
||||
viewer_model.load_shared_session_scrollback(
|
||||
&[
|
||||
serialized_block,
|
||||
SerializedBlock::new_active_block_for_test(),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block()
|
||||
.unwrap()
|
||||
.output_grid()
|
||||
.contents_to_string(true, None)
|
||||
.trim(),
|
||||
output,
|
||||
);
|
||||
|
||||
(sharer_model, viewer_model)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selections_across_different_filtered_blocklists() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|ctx| {
|
||||
// Create a model for sharer / viewer with the following block:
|
||||
// the command grid in this test should simply be "ls", and
|
||||
// the output grid should look like
|
||||
// ```
|
||||
// foo
|
||||
// bar
|
||||
// baz
|
||||
// ```
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let (mut sharer_model, mut viewer_model) =
|
||||
create_sharer_and_viewer_models_with_same_block("ls", "foo\r\nbar\r\nbaz");
|
||||
|
||||
// Suppose the sharer filters the block down with filter="ba".
|
||||
let block_index = sharer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
sharer_model.update_filter_on_block(
|
||||
block_index,
|
||||
BlockFilterQuery::new_for_test(String::from("ba")),
|
||||
);
|
||||
assert_eq!(
|
||||
sharer_model
|
||||
.block_list()
|
||||
.num_matched_lines_in_filter_for_block(block_index),
|
||||
Some(2)
|
||||
);
|
||||
|
||||
// Now, suppose the sharer selects "bar", which is on the first row of the output grid, post-filter.
|
||||
create_simple_text_selection(
|
||||
&mut sharer_model,
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(0, 0),
|
||||
},
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(0, 2),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
sharer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"bar"
|
||||
);
|
||||
|
||||
// Convert the selection to session-sharing-compatible points
|
||||
// that the viewer can apply locally.
|
||||
let (start, end, _) = sharer_model
|
||||
.block_list()
|
||||
.text_selection_range(&semantic_selection, false)
|
||||
.unwrap();
|
||||
let start_converted = start
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
let end_converted = end
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
|
||||
// Suppose the viewer receives these points; convert them to local points.
|
||||
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
start_converted.clone(),
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
end_converted.clone(),
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"bar"
|
||||
);
|
||||
|
||||
// Even if the viewer filters further, the selection should still be stable.
|
||||
let block_index = viewer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
viewer_model.block_list_mut().clear_selection();
|
||||
viewer_model.update_filter_on_block(
|
||||
block_index,
|
||||
BlockFilterQuery::new_for_test(String::from("ba")),
|
||||
);
|
||||
|
||||
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
start_converted,
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
end_converted,
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"bar"
|
||||
);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_of_undisplayed_row() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|ctx| {
|
||||
// Create a model for the sharer and create the following block:
|
||||
// the command grid in this test should simply be "ls", and
|
||||
// the output grid should look like
|
||||
// ```
|
||||
// foo
|
||||
// bar
|
||||
// baz
|
||||
// ```
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let (mut sharer_model, mut viewer_model) =
|
||||
create_sharer_and_viewer_models_with_same_block("ls", "foo\r\nbar\r\nbaz");
|
||||
|
||||
// Suppose the viewer filters down to lines with "ba".
|
||||
let block_index = viewer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
viewer_model.block_list_mut().clear_selection();
|
||||
viewer_model.update_filter_on_block(
|
||||
block_index,
|
||||
BlockFilterQuery::new_for_test(String::from("bar")),
|
||||
);
|
||||
|
||||
// Suppose the sharer selects "foo".
|
||||
let block_index = sharer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
create_simple_text_selection(
|
||||
&mut sharer_model,
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(0, 0),
|
||||
},
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(0, 2),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
sharer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"foo"
|
||||
);
|
||||
|
||||
// Convert the selection to session-sharing-compatible points
|
||||
// that the viewer can apply locally.
|
||||
let (start, end, _) = sharer_model
|
||||
.block_list()
|
||||
.text_selection_range(&semantic_selection, false)
|
||||
.unwrap();
|
||||
let start_converted = start
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
let end_converted = end
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
|
||||
// Suppose the viewer receives these points and tries to convert them to local points.
|
||||
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
start_converted.clone(),
|
||||
viewer_model.block_list(),
|
||||
);
|
||||
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
end_converted.clone(),
|
||||
viewer_model.block_list(),
|
||||
);
|
||||
|
||||
// These points shouldn't exist because the viewer has a filter that
|
||||
// excludes "foo" from the output grid.
|
||||
assert!(viewer_start.is_none());
|
||||
assert!(viewer_end.is_none());
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selections_from_larger_grid_to_smaller_grid() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|ctx| {
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let (mut sharer_model, mut viewer_model) =
|
||||
create_sharer_and_viewer_models_with_same_block(
|
||||
"ls",
|
||||
"\
|
||||
this is some long line\r\n\
|
||||
short line\r\n\
|
||||
this is another long line\r\n\
|
||||
short line 2",
|
||||
);
|
||||
|
||||
// Make sure the viewer only has 10 columns, while the sharer has 50.
|
||||
let sharer_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: *sharer_model.block_list().size(),
|
||||
new_size: SizeInfo::new_without_font_metrics(100, 50),
|
||||
new_gap_height: None,
|
||||
natural_rows: 100,
|
||||
natural_cols: 50,
|
||||
};
|
||||
sharer_model.block_list_mut().resize(&sharer_update, true);
|
||||
|
||||
let viewer_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: *viewer_model.block_list().size(),
|
||||
new_size: SizeInfo::new_without_font_metrics(100, 10),
|
||||
new_gap_height: None,
|
||||
natural_rows: 100,
|
||||
natural_cols: 10,
|
||||
};
|
||||
viewer_model.block_list_mut().resize(&viewer_update, true);
|
||||
|
||||
// Suppose the sharer selects "line" on the third line.
|
||||
let block_index = sharer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
create_simple_text_selection(
|
||||
&mut sharer_model,
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(2, 21),
|
||||
},
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(2, 25),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
sharer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"line"
|
||||
);
|
||||
|
||||
// Convert the selection to session-sharing-compatible points
|
||||
// that the viewer can apply locally.
|
||||
let (start, end, _) = sharer_model
|
||||
.block_list()
|
||||
.text_selection_range(&semantic_selection, false)
|
||||
.unwrap();
|
||||
let start_converted = start
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
let end_converted = end
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
|
||||
// Suppose the viewer receives these points and tries to convert them to local points.
|
||||
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
start_converted,
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
end_converted,
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"line"
|
||||
);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selections_from_smaller_grid_to_larger_grid() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|ctx| {
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let (mut sharer_model, mut viewer_model) =
|
||||
create_sharer_and_viewer_models_with_same_block(
|
||||
"ls",
|
||||
"\
|
||||
this is some long line\r\n\
|
||||
short line\r\n\
|
||||
this is another long line\r\n\
|
||||
short line 2",
|
||||
);
|
||||
|
||||
// Make sure the sharer only has 10 columns, while the viewer has 50.
|
||||
let sharer_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: *sharer_model.block_list().size(),
|
||||
new_size: SizeInfo::new_without_font_metrics(100, 10),
|
||||
new_gap_height: None,
|
||||
natural_rows: 100,
|
||||
natural_cols: 10,
|
||||
};
|
||||
sharer_model.block_list_mut().resize(&sharer_update, true);
|
||||
|
||||
let viewer_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: *viewer_model.block_list().size(),
|
||||
new_size: SizeInfo::new_without_font_metrics(100, 50),
|
||||
new_gap_height: None,
|
||||
natural_rows: 100,
|
||||
natural_cols: 50,
|
||||
};
|
||||
viewer_model.block_list_mut().resize(&viewer_update, true);
|
||||
|
||||
// Suppose the sharer selects "line" on the third line
|
||||
// (which is actually the 6th line due to wrapping).
|
||||
let block_index = sharer_model
|
||||
.block_list()
|
||||
.last_non_hidden_block_by_index()
|
||||
.unwrap();
|
||||
create_simple_text_selection(
|
||||
&mut sharer_model,
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(6, 1),
|
||||
},
|
||||
WithinBlock {
|
||||
block_index,
|
||||
grid: GridType::Output,
|
||||
inner: Point::new(6, 4),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
sharer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"line"
|
||||
);
|
||||
|
||||
// Convert the selection to session-sharing-compatible points
|
||||
// that the viewer can apply locally.
|
||||
let (start, end, _) = sharer_model
|
||||
.block_list()
|
||||
.text_selection_range(&semantic_selection, false)
|
||||
.unwrap();
|
||||
let start_converted = start
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
let end_converted = end
|
||||
.to_session_sharing_block_point(sharer_model.block_list())
|
||||
.unwrap();
|
||||
|
||||
// Suppose the viewer receives these points and tries to convert them to local points.
|
||||
let viewer_start = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
start_converted.clone(),
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
let viewer_end = WithinBlock::<Point>::from_session_sharing_block_point(
|
||||
end_converted,
|
||||
viewer_model.block_list(),
|
||||
)
|
||||
.unwrap();
|
||||
create_simple_text_selection(&mut viewer_model, viewer_start, viewer_end);
|
||||
assert_eq!(
|
||||
viewer_model
|
||||
.selection_to_string(&semantic_selection, false, ctx)
|
||||
.unwrap(),
|
||||
"line"
|
||||
);
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(SharedSessionSettings, settings: [
|
||||
onboarding_block_shown: SessionSharingOnboardingBlockShown {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
inactivity_period_before_ending_session: InactivityPeriodBeforeEndingSession {
|
||||
type: Duration,
|
||||
// After a total of 30 min of inactivity, we will end the session
|
||||
default: Duration::from_secs(1800),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
inactivity_period_before_warning: InactivityPeriodBeforeWarning {
|
||||
type: Duration,
|
||||
// After a total of 25 min of inactivity, we will show a warning modal
|
||||
default: Duration::from_secs(1500),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
inactivity_period_before_revoking_roles: InactivityPeriodBeforeRevokingRoles {
|
||||
type: Duration,
|
||||
// After a total of 10 min of inactivity, we will revoke all executor roles
|
||||
default: Duration::from_secs(600),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
// Killswitch: when false, the sharer ignores viewer terminal size reports.
|
||||
viewer_driven_sizing_enabled: ViewerDrivenSizingEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
|
||||
impl SharedSessionSettings {
|
||||
/// Returns time between showing the inactivity warning modal and ending the session.
|
||||
pub fn inactivity_period_between_warning_and_ending_session(&self) -> Duration {
|
||||
*self.inactivity_period_before_ending_session.value()
|
||||
- *self.inactivity_period_before_warning.value()
|
||||
}
|
||||
|
||||
/// Returns time between revoking roles and showing the inactivity warning modal.
|
||||
pub fn inactivity_period_between_revoking_roles_and_warning(&self) -> Duration {
|
||||
*self.inactivity_period_before_warning.value()
|
||||
- *self.inactivity_period_before_revoking_roles.value()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations;
|
||||
use crate::terminal::shared_session::role_change_modal::TEXT_FONT_SIZE;
|
||||
use crate::terminal::shared_session::{
|
||||
ai_agent::encode_agent_response_event, max_session_size, SharedSessionActionSource,
|
||||
SharedSessionScrollbackType,
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
use byte_unit::Byte;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use std::default::Default;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use warpui::elements::{
|
||||
Container, Flex, MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::ui_components::radio_buttons::{
|
||||
RadioButtonItem, RadioButtonLayout, RadioButtonStateHandle,
|
||||
};
|
||||
|
||||
use super::style::{self, BUTTON_GAP, MODAL_MARGIN};
|
||||
use warpui::{
|
||||
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonMouseStateHandles {
|
||||
cancel_button: MouseStateHandle,
|
||||
start_sharing_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RadioButtonGroupState {
|
||||
group_state_handle: RadioButtonStateHandle,
|
||||
items: Vec<ScrollbackOption>,
|
||||
}
|
||||
|
||||
struct ScrollbackOption {
|
||||
label: &'static str,
|
||||
scrollback_type: SharedSessionScrollbackType,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
is_disabled: bool,
|
||||
}
|
||||
|
||||
pub struct Body {
|
||||
button_mouse_states: ButtonMouseStateHandles,
|
||||
radio_button_mouse_states: RadioButtonGroupState,
|
||||
has_agent_conversations: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BodyAction {
|
||||
StartSharing,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub enum BodyEvent {
|
||||
Close,
|
||||
StartSharing {
|
||||
scrollback_type: SharedSessionScrollbackType,
|
||||
},
|
||||
}
|
||||
|
||||
impl Body {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
button_mouse_states: Default::default(),
|
||||
radio_button_mouse_states: Default::default(),
|
||||
has_agent_conversations: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the total size of agent conversation response events that will be sent
|
||||
/// during session initialization. This is important because these events count toward
|
||||
/// the session size quota, but are separate from the scrollback blocks.
|
||||
fn calculate_agent_conversations_size(
|
||||
terminal_view_id: warpui::EntityId,
|
||||
ctx: &ViewContext<Self>,
|
||||
) -> Byte {
|
||||
let conversations: Vec<_> = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.all_live_conversations_for_terminal_view(terminal_view_id)
|
||||
.filter(|conv| conv.exchange_count() > 0)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let total_bytes: usize = reconstruct_response_events_from_conversations(&conversations)
|
||||
.iter()
|
||||
.map(|event| encode_agent_response_event(event).len())
|
||||
.sum();
|
||||
|
||||
Byte::from_u64(total_bytes as u64)
|
||||
}
|
||||
}
|
||||
|
||||
impl Body {
|
||||
pub fn open(
|
||||
&mut self,
|
||||
open_source: SharedSessionActionSource,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view_id: warpui::EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let model = model.lock();
|
||||
let max_session_size = max_session_size(ctx);
|
||||
|
||||
// TODO: serializing the blocks to compute their sizes is
|
||||
// inefficient but it matches how the server checks limits.
|
||||
// Consider caching size in the blocklist as blocks are added
|
||||
// so we can compute this more efficiently if latency becomes an issue.
|
||||
// This is not an issue in release mode.
|
||||
|
||||
// TODO: technically, the size of the long-running block might change while the modal
|
||||
// is open. We might want to watch for changes on the terminal model and recompute
|
||||
// the size here accordingly. That being said, we still have guardrails on both
|
||||
// client and server to ensure that the actual share won't be started if the size is
|
||||
// too large.
|
||||
|
||||
// Check if agent shared sessions is enabled and there are active conversations
|
||||
self.has_agent_conversations = if FeatureFlag::AgentSharedSessions.is_enabled() {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.all_live_conversations_for_terminal_view(terminal_view_id)
|
||||
.any(|conv| conv.exchange_count() > 0)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Calculate the size of agent conversation response events that will be sent during initialization.
|
||||
// Only include this if the feature flag is enabled, since the events won't be sent otherwise.
|
||||
let agent_conversations_size =
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled() && self.has_agent_conversations {
|
||||
Self::calculate_agent_conversations_size(terminal_view_id, ctx)
|
||||
} else {
|
||||
Byte::from_u64(0)
|
||||
};
|
||||
|
||||
let scrollback_from_active_block = SharedSessionScrollbackType::None.to_scrollback(&model);
|
||||
let mut is_scrollback_from_active_block_disabled = scrollback_from_active_block
|
||||
.num_bytes()
|
||||
.as_u64()
|
||||
.saturating_add(agent_conversations_size.as_u64())
|
||||
> max_session_size.as_u64();
|
||||
|
||||
// Disable the "without scrollback" option if there are agent conversations
|
||||
if self.has_agent_conversations {
|
||||
is_scrollback_from_active_block_disabled = true;
|
||||
}
|
||||
|
||||
let all_scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
||||
let is_all_scrollback_disabled = all_scrollback
|
||||
.num_bytes()
|
||||
.as_u64()
|
||||
.saturating_add(agent_conversations_size.as_u64())
|
||||
> max_session_size.as_u64();
|
||||
|
||||
let scrollback_from_active_block_message = if model.is_alt_screen_active() {
|
||||
"Share from current screen"
|
||||
} else if model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.is_active_and_long_running()
|
||||
{
|
||||
"Share from current block"
|
||||
} else {
|
||||
"Share without scrollback"
|
||||
};
|
||||
|
||||
let mut options = vec![
|
||||
ScrollbackOption {
|
||||
label: scrollback_from_active_block_message,
|
||||
scrollback_type: SharedSessionScrollbackType::None,
|
||||
mouse_state_handle: Default::default(),
|
||||
is_disabled: is_scrollback_from_active_block_disabled,
|
||||
},
|
||||
ScrollbackOption {
|
||||
label: "Share from start of session",
|
||||
scrollback_type: SharedSessionScrollbackType::All,
|
||||
mouse_state_handle: Default::default(),
|
||||
is_disabled: is_all_scrollback_disabled,
|
||||
},
|
||||
];
|
||||
|
||||
if let SharedSessionActionSource::BlocklistContextMenu {
|
||||
block_index: Some(block_index),
|
||||
} = open_source
|
||||
{
|
||||
// Context menu from blocklist can be opened with or without block selection
|
||||
// Add option only if a block is selected
|
||||
let scrollback_type = SharedSessionScrollbackType::FromBlock { block_index };
|
||||
let mut is_disabled = if !is_all_scrollback_disabled {
|
||||
false
|
||||
} else {
|
||||
let block_scrollback = scrollback_type.to_scrollback(&model);
|
||||
block_scrollback
|
||||
.num_bytes()
|
||||
.as_u64()
|
||||
.saturating_add(agent_conversations_size.as_u64())
|
||||
> max_session_size.as_u64()
|
||||
};
|
||||
|
||||
// Disable this option if there are agent conversations in the current session.
|
||||
if self.has_agent_conversations {
|
||||
is_disabled = true;
|
||||
}
|
||||
|
||||
options.insert(
|
||||
0,
|
||||
ScrollbackOption {
|
||||
label: "Share from selected block and onwards",
|
||||
scrollback_type,
|
||||
mouse_state_handle: Default::default(),
|
||||
is_disabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.radio_button_mouse_states.items = options;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Body {
|
||||
type Event = BodyEvent;
|
||||
}
|
||||
|
||||
impl View for Body {
|
||||
fn ui_name() -> &'static str {
|
||||
"ShareSessionModalBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut start_sharing_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.button_mouse_states.start_sharing_button.clone(),
|
||||
)
|
||||
.with_centered_text_label(String::from("Start sharing"))
|
||||
.with_style(style::button_styles());
|
||||
|
||||
// If none of the scrollback options are available, the start sharing
|
||||
// button should be disabled.
|
||||
if self
|
||||
.radio_button_mouse_states
|
||||
.items
|
||||
.iter()
|
||||
.all(|item| item.is_disabled)
|
||||
{
|
||||
start_sharing_button = start_sharing_button.disabled();
|
||||
}
|
||||
|
||||
let start_sharing_button = start_sharing_button
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(BodyAction::StartSharing))
|
||||
.finish();
|
||||
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.button_mouse_states.cancel_button.clone(),
|
||||
)
|
||||
.with_centered_text_label(String::from("Cancel"))
|
||||
.with_style(style::button_styles())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(BodyAction::Cancel))
|
||||
.finish();
|
||||
|
||||
// When agent conversations exist, default to "Share from start of session"
|
||||
let default_option = self
|
||||
.radio_button_mouse_states
|
||||
.items
|
||||
.iter()
|
||||
.position(|i| !i.is_disabled);
|
||||
|
||||
let radio_buttons = appearance
|
||||
.ui_builder()
|
||||
.radio_buttons(
|
||||
self.radio_button_mouse_states
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.mouse_state_handle.clone())
|
||||
.collect(),
|
||||
self.radio_button_mouse_states
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| RadioButtonItem::text(i.label).with_disabled(i.is_disabled))
|
||||
.collect(),
|
||||
self.radio_button_mouse_states.group_state_handle.clone(),
|
||||
default_option,
|
||||
TEXT_FONT_SIZE,
|
||||
RadioButtonLayout::Column,
|
||||
)
|
||||
.with_style(style::radio_button_styles());
|
||||
|
||||
let button_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Container::new(cancel_button)
|
||||
.with_margin_right(BUTTON_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Container::new(start_sharing_button)
|
||||
.with_margin_left(BUTTON_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut column = Flex::column();
|
||||
// Determine which explanation message to show
|
||||
let disabled_count = self
|
||||
.radio_button_mouse_states
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| i.is_disabled)
|
||||
.count();
|
||||
|
||||
let explanation_message = if disabled_count == 0 {
|
||||
None
|
||||
} else if disabled_count > 1 {
|
||||
// Multiple options disabled - mention both reasons if agent conversations exist
|
||||
if self.has_agent_conversations {
|
||||
Some("Some options are disabled due to sharing size limits and the presence of agent conversations in the session")
|
||||
} else {
|
||||
Some("Some options are disabled due to sharing size limits")
|
||||
}
|
||||
} else {
|
||||
// Only one option disabled - use specific message if it's due to agent conversations
|
||||
if self.has_agent_conversations {
|
||||
Some("Sharing without scrollback is disabled because this session has agent conversations")
|
||||
} else {
|
||||
Some("Some options are disabled due to sharing size limits")
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(message) = explanation_message {
|
||||
column.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
message,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(-MODAL_MARGIN)
|
||||
.with_margin_bottom(MODAL_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
column
|
||||
.with_child(radio_buttons.build().finish())
|
||||
.with_child(
|
||||
Container::new(button_row.finish())
|
||||
.with_margin_top(MODAL_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for Body {
|
||||
type Action = BodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
BodyAction::Cancel => ctx.emit(BodyEvent::Close),
|
||||
BodyAction::StartSharing => {
|
||||
if let Some(selected_option) = self
|
||||
.radio_button_mouse_states
|
||||
.group_state_handle
|
||||
.get_selected_idx()
|
||||
.and_then(|idx| self.radio_button_mouse_states.items.get(idx))
|
||||
{
|
||||
ctx.emit(BodyEvent::StartSharing {
|
||||
scrollback_type: selected_option.scrollback_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "body_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,304 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use warpui::App;
|
||||
|
||||
use crate::terminal::shared_session::MAX_BYTES_SHAREABLE;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::{
|
||||
terminal::shared_session::{SharedSessionActionSource, SharedSessionScrollbackType},
|
||||
test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view},
|
||||
};
|
||||
|
||||
use super::Body;
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_non_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
|
||||
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
// Share from the tab.
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::Tab;
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// Options should be no scrollback and from start. Both enabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
|
||||
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
// Add a block that is under the limit.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
|
||||
// Share from the very large block we just completed.
|
||||
let block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::BlocklistContextMenu {
|
||||
block_index: Some(block_index),
|
||||
};
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// Options should be from block, no scrollback, and from start. All enabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::FromBlock { block_index },
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[2].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[2].is_disabled);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_non_block_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
|
||||
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
// Add a block that is under the limit.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
|
||||
// Add another block that puts us over the sharing limit for the whole session.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4. This plus the earlier block puts us over the limit.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
|
||||
// Share from the tab.
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::Tab;
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// Options should be no scrollback and from start. From start is disabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[1].is_disabled);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_block_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
|
||||
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
// Add a block that is under the limit.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
let mut block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
|
||||
|
||||
// Add another block that puts us over the sharing limit for the whole session.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4. This plus the earlier block puts us over the limit.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
|
||||
// Open modal from the first very large block.
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::BlocklistContextMenu {
|
||||
block_index: Some(block_index),
|
||||
};
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// From block and from start of the session are disabled because they are over the limit. No scrollback is enabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::FromBlock { block_index }
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[2].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[2].is_disabled);
|
||||
});
|
||||
|
||||
// Open modal from the newly finished block, which excludes the very large first block we created.
|
||||
block_index = terminal_model.lock().block_list().active_block_index() - 1.into();
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::BlocklistContextMenu {
|
||||
block_index: Some(block_index),
|
||||
};
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// From block and no scrollback are enabled, but from start of session is disabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 3);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::FromBlock { block_index }
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[2].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[2].is_disabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_modal_from_long_running_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
let window_id = app.read(|ctx| terminal_view.window_id(ctx));
|
||||
let share_session_modal = app.add_typed_action_view(window_id, Body::new);
|
||||
|
||||
// Add a long-running block that is under the limit.
|
||||
// `serde_json::to_vec` roughly triples the size of the SerializedBlock output, which is why we divide by 4.
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_long_running_block("ls", "a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
assert_eq!(terminal_model.lock().block_list().blocks().len(), 2);
|
||||
assert!(terminal_model
|
||||
.lock()
|
||||
.block_list()
|
||||
.active_block()
|
||||
.is_executing());
|
||||
|
||||
// Open the share modal.
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::Tab;
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// Options should be no scrollback and from start. Both are enabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(!body.radio_button_mouse_states.items[1].is_disabled);
|
||||
});
|
||||
|
||||
// Add more output to block so that it's over the limit.
|
||||
terminal_model
|
||||
.lock()
|
||||
.process_bytes("a".repeat(MAX_BYTES_SHAREABLE / 4).as_str());
|
||||
assert_eq!(terminal_model.lock().block_list().blocks().len(), 2);
|
||||
assert!(terminal_model
|
||||
.lock()
|
||||
.block_list()
|
||||
.active_block()
|
||||
.is_executing());
|
||||
|
||||
// Re-open the modal to refresh the options.
|
||||
let terminal_model_clone = terminal_model.clone();
|
||||
share_session_modal.update(&mut app, |share_session_modal, ctx| {
|
||||
let open_source = SharedSessionActionSource::Tab;
|
||||
share_session_modal.open(open_source, terminal_model_clone, terminal_view.id(), ctx);
|
||||
});
|
||||
|
||||
// Options should be no scrollback and from start. Both are disabled.
|
||||
share_session_modal.read(&app, |body, _ctx| {
|
||||
assert_eq!(body.radio_button_mouse_states.items.len(), 2);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[0].scrollback_type,
|
||||
SharedSessionScrollbackType::None
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[0].is_disabled);
|
||||
assert_eq!(
|
||||
body.radio_button_mouse_states.items[1].scrollback_type,
|
||||
SharedSessionScrollbackType::All
|
||||
);
|
||||
assert!(body.radio_button_mouse_states.items[1].is_disabled);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use crate::appearance::Appearance;
|
||||
use warpui::elements::{Container, Flex, MainAxisSize, MouseStateHandle, ParentElement};
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
platform::Cursor, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
use super::style::{self, MODAL_PADDING};
|
||||
|
||||
const SESSION_BUILD_FREE_PLAN_SUBHEADER: &str = "Warp's free and pro plans come with a limited number of shared sessions.\n\nFor increased access to session sharing upgrade to the Build plan.";
|
||||
const VIEW_PLANS_TEXT: &str = "View plans";
|
||||
|
||||
pub struct DeniedBody {
|
||||
button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum DeniedBodyAction {
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
pub enum DeniedBodyEvent {
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
impl DeniedBody {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DeniedBody {
|
||||
type Event = DeniedBodyEvent;
|
||||
}
|
||||
|
||||
impl View for DeniedBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"ShareModalDeniedBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut col = Flex::column();
|
||||
let subheader = SESSION_BUILD_FREE_PLAN_SUBHEADER;
|
||||
|
||||
let text = appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(subheader, true)
|
||||
.with_style(style::subheader_styles(appearance))
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.button_mouse_state.clone())
|
||||
.with_centered_text_label(VIEW_PLANS_TEXT.to_owned())
|
||||
.with_style(style::button_styles())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(DeniedBodyAction::Upgrade))
|
||||
.finish();
|
||||
|
||||
col.add_child(text);
|
||||
col.add_child(
|
||||
Container::new(button)
|
||||
.with_margin_top(MODAL_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
col.with_main_axis_size(MainAxisSize::Min).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for DeniedBody {
|
||||
type Action = DeniedBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
DeniedBodyAction::Upgrade => ctx.emit(DeniedBodyEvent::Upgrade),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use crate::modal::Modal;
|
||||
|
||||
use crate::modal::ModalEvent;
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
use std::default::Default;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use style::{DENIED_MODAL_WIDTH, MODAL_HEIGHT, MODAL_WIDTH};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::EntityId;
|
||||
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::ui_components::components::UiComponentStyles;
|
||||
use warpui::AppContext;
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::ViewHandle;
|
||||
use warpui::{Element, Entity, TypedActionView, View, ViewContext};
|
||||
|
||||
mod body;
|
||||
mod denied_body;
|
||||
mod style;
|
||||
|
||||
use body::Body;
|
||||
use denied_body::{DeniedBody, DeniedBodyEvent};
|
||||
|
||||
use self::body::BodyEvent;
|
||||
|
||||
use super::{SharedSessionActionSource, SharedSessionScrollbackType};
|
||||
|
||||
const MODAL_HEADER: &str = "Share session";
|
||||
const SESSION_LIMIT_REACHED_HEADER: &str = "Shared session limit reached";
|
||||
|
||||
pub struct ShareSessionModal {
|
||||
modal: ViewHandle<Modal<Body>>,
|
||||
denied_modal: ViewHandle<Modal<DeniedBody>>,
|
||||
is_denied_modal_open: bool,
|
||||
terminal_pane_id: Option<TerminalPaneId>,
|
||||
/// Where we opened the modal from.
|
||||
open_source: SharedSessionActionSource,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ShareSessionModalAction {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub enum ShareSessionModalEvent {
|
||||
Close,
|
||||
StartSharing {
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
scrollback_type: SharedSessionScrollbackType,
|
||||
source: SharedSessionActionSource,
|
||||
},
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
ShareSessionModalAction::Cancel,
|
||||
id!("ShareSessionModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
impl ShareSessionModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let body = ctx.add_typed_action_view(Body::new);
|
||||
ctx.subscribe_to_view(&body, move |me, _, event, ctx| {
|
||||
me.handle_body_event(event, ctx);
|
||||
});
|
||||
|
||||
let modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(Some(MODAL_HEADER.to_string()), body, ctx)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
width: Some(MODAL_WIDTH),
|
||||
height: Some(MODAL_HEIGHT),
|
||||
..Default::default()
|
||||
})
|
||||
.with_header_style(style::modal_header_styles())
|
||||
.with_body_style(style::modal_body_styles())
|
||||
.with_background_opacity(100)
|
||||
.with_dismiss_on_click()
|
||||
.close_modal_button_disabled()
|
||||
});
|
||||
|
||||
let denied_body = ctx.add_typed_action_view(DeniedBody::new);
|
||||
ctx.subscribe_to_view(&denied_body, move |me, _, event, ctx| {
|
||||
me.handle_denied_body_event(event, ctx)
|
||||
});
|
||||
let denied_modal = ctx.add_typed_action_view(|ctx| {
|
||||
let mut denied_modal = Modal::new(
|
||||
Some(SESSION_LIMIT_REACHED_HEADER.to_string()),
|
||||
denied_body,
|
||||
ctx,
|
||||
)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
width: Some(DENIED_MODAL_WIDTH),
|
||||
..Default::default()
|
||||
})
|
||||
.with_header_style(style::modal_header_styles())
|
||||
.with_body_style(style::modal_body_styles())
|
||||
.with_background_opacity(100)
|
||||
.with_dismiss_on_click();
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
denied_modal.set_header_icon(Some(Icon::Share));
|
||||
denied_modal.set_header_icon_color(Some(appearance.theme().accent()));
|
||||
denied_modal
|
||||
});
|
||||
ctx.subscribe_to_view(&denied_modal, |me, _, event, ctx| match event {
|
||||
ModalEvent::Close => me.close(ctx),
|
||||
});
|
||||
|
||||
Self {
|
||||
modal,
|
||||
denied_modal,
|
||||
is_denied_modal_open: false,
|
||||
terminal_pane_id: None,
|
||||
// This should get overwritten when the modal is actually opened.
|
||||
open_source: SharedSessionActionSource::Tab,
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the share session modal. If `focus_pane` is `true` we will return focus to the most
|
||||
/// recently focused pane.
|
||||
fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.terminal_pane_id.is_none() {
|
||||
log::warn!("Tried to close share modal when no terminal pane ID was present");
|
||||
return;
|
||||
};
|
||||
self.is_denied_modal_open = false;
|
||||
ctx.emit(ShareSessionModalEvent::Close);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn start_sharing(
|
||||
&mut self,
|
||||
scrollback_type: SharedSessionScrollbackType,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(terminal_pane_id) = self.terminal_pane_id else {
|
||||
return;
|
||||
};
|
||||
ctx.emit(ShareSessionModalEvent::StartSharing {
|
||||
terminal_pane_id,
|
||||
scrollback_type,
|
||||
source: self.open_source,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
&mut self,
|
||||
terminal_pane_id: TerminalPaneId,
|
||||
open_source: SharedSessionActionSource,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.terminal_pane_id = Some(terminal_pane_id);
|
||||
self.open_source = open_source;
|
||||
self.modal.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |modal, ctx| {
|
||||
modal.open(open_source, model, terminal_view_id, ctx);
|
||||
});
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn open_denied(&mut self, terminal_pane_id: TerminalPaneId, ctx: &mut ViewContext<Self>) {
|
||||
self.terminal_pane_id = Some(terminal_pane_id);
|
||||
self.open_source = SharedSessionActionSource::NonUser;
|
||||
self.is_denied_modal_open = true;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_body_event(&mut self, event: &BodyEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
BodyEvent::Close => self.close(ctx),
|
||||
BodyEvent::StartSharing { scrollback_type } => {
|
||||
self.start_sharing(*scrollback_type, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_denied_body_event(&mut self, event: &DeniedBodyEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
DeniedBodyEvent::Upgrade => {
|
||||
self.close(ctx);
|
||||
ctx.emit(ShareSessionModalEvent::Upgrade)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn terminal_pane_id(&self) -> Option<TerminalPaneId> {
|
||||
self.terminal_pane_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ShareSessionModal {
|
||||
type Event = ShareSessionModalEvent;
|
||||
}
|
||||
|
||||
impl View for ShareSessionModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"ShareSessionModal"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
if self.is_denied_modal_open {
|
||||
ChildView::new(&self.denied_modal).finish()
|
||||
} else {
|
||||
ChildView::new(&self.modal).finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ShareSessionModal {
|
||||
type Action = ShareSessionModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ShareSessionModalAction::Cancel => self.close(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
fonts::Weight,
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
};
|
||||
|
||||
pub const MODAL_WIDTH: f32 = 460.;
|
||||
pub const MODAL_HEIGHT: f32 = 300.;
|
||||
pub const DENIED_MODAL_WIDTH: f32 = 355.;
|
||||
pub const MODAL_PADDING: f32 = 24.;
|
||||
pub const MODAL_MARGIN: f32 = 16.;
|
||||
pub const BUTTON_GAP: f32 = 4.;
|
||||
const TEXT_FONT_SIZE: f32 = 14.;
|
||||
const HEADER_FONT_SIZE: f32 = 16.;
|
||||
|
||||
pub fn modal_header_styles() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 0.,
|
||||
bottom: 0.,
|
||||
left: MODAL_PADDING,
|
||||
right: MODAL_PADDING,
|
||||
}),
|
||||
font_size: Some(HEADER_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modal_body_styles() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 0.,
|
||||
bottom: MODAL_PADDING,
|
||||
left: MODAL_PADDING,
|
||||
right: MODAL_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_styles() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(40.),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn radio_button_styles() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 16.,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subheader_styles(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use input_classifier::InputType;
|
||||
use session_sharing_protocol::common::{
|
||||
CLIAgentSessionState, InputMode, InputType as ProtocolInputType, SelectedAgentModel,
|
||||
SelectedConversation, ServerConversationToken, UniversalDeveloperInputContextUpdate,
|
||||
};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, ModelHandle, SingletonEntity, WeakViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
|
||||
use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIHistoryModel, InputConfig};
|
||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession,
|
||||
CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel,
|
||||
};
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
/// Handles updating the local LLM preferences when a selected agent model update is received.
|
||||
/// This function is shared between the viewer and sharer to ensure consistent behavior.
|
||||
pub(crate) fn apply_selected_agent_model_update(
|
||||
terminal_view_id: warpui::EntityId,
|
||||
selected_model: &SelectedAgentModel,
|
||||
_guard: &ActiveRemoteUpdate,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let model_id = LLMId::from(selected_model.model_id().to_owned());
|
||||
|
||||
// Check if this is already our current model - if so, skip the update to avoid loops
|
||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||
let current_model_id = llm_prefs
|
||||
.get_active_base_model(ctx, Some(terminal_view_id))
|
||||
.id
|
||||
.clone();
|
||||
if current_model_id == model_id {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the model is available to the viewer. If not, skip the update.
|
||||
// This handles cases where the viewer and sharer have different model permissions.
|
||||
let model_is_available = llm_prefs
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.any(|info| info.id == model_id);
|
||||
if !model_is_available {
|
||||
log::warn!("Skipping shared-session model update - {model_id} is unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Selecting base agent model {model_id} (from session sharing update)");
|
||||
|
||||
// Update the local LLMPreferences to match the selected model
|
||||
LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| {
|
||||
prefs.update_preferred_agent_mode_llm(&model_id, terminal_view_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles updating the local input mode when an input mode update is received.
|
||||
/// This function is shared between the viewer and sharer to ensure consistent behavior.
|
||||
pub(crate) fn apply_input_mode_update(
|
||||
weak_view_handle: &WeakViewHandle<TerminalView>,
|
||||
input_mode: &InputMode,
|
||||
_guard: &ActiveRemoteUpdate,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(view) = weak_view_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// When AgentView is enabled, we only apply input mode updates when in an active agent view.
|
||||
// Outside of agent view, input mode changes are not relevant.
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
|
||||
if !agent_view_controller.as_ref(ctx).is_active() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let client_input_type = match input_mode.input_type {
|
||||
ProtocolInputType::Shell => InputType::Shell,
|
||||
ProtocolInputType::AI => InputType::AI,
|
||||
};
|
||||
let new_config = InputConfig {
|
||||
input_type: client_input_type,
|
||||
is_locked: input_mode.is_locked,
|
||||
};
|
||||
|
||||
// Skip update if nothing would change
|
||||
let current_config = view.as_ref(ctx).input_config(ctx);
|
||||
if current_config == new_config {
|
||||
return;
|
||||
}
|
||||
|
||||
view.update(ctx, |terminal_view, ctx| {
|
||||
terminal_view.apply_external_input_mode_update(new_config, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles updating the local auto-approve setting when an update is received.
|
||||
/// This function is shared between the viewer and sharer to ensure consistent behavior.
|
||||
pub(crate) fn apply_auto_approve_agent_actions_update(
|
||||
weak_view_handle: &WeakViewHandle<TerminalView>,
|
||||
auto_approve: bool,
|
||||
_guard: &ActiveRemoteUpdate,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(view) = weak_view_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
view.update(ctx, |view, ctx| {
|
||||
let ai_context_model = view.ai_context_model().clone();
|
||||
ai_context_model.update(ctx, |context_model, ctx| {
|
||||
let current_mode = context_model.pending_query_autoexecute_override(ctx);
|
||||
let is_on = current_mode.is_autoexecute_any_action();
|
||||
|
||||
// Skip if we're already in the desired state to avoid feedback loops.
|
||||
if is_on == auto_approve {
|
||||
return;
|
||||
}
|
||||
|
||||
context_model.toggle_pending_query_autoexecute(ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles updating the local selected conversation when a selected conversation update is received.
|
||||
/// This function is shared between the viewer and sharer to ensure consistent behavior.
|
||||
pub(crate) fn apply_selected_conversation_update(
|
||||
weak_view_handle: &WeakViewHandle<TerminalView>,
|
||||
selected_conversation: &SelectedConversation,
|
||||
_guard: &ActiveRemoteUpdate,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(view) = weak_view_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// In shared ambient agent sessions, we can temporarily receive "none/new" selected_conversation
|
||||
// updates (e.g. before a server conversation token exists).
|
||||
//
|
||||
// If we already have an active local conversation selected (typically created/selected by the
|
||||
// incoming shared-session init event), treating these updates as authoritative can create an
|
||||
// extra empty conversation on the viewer.
|
||||
//
|
||||
// To avoid that, ignore "none/new" updates once there is already an active *empty* conversation.
|
||||
if view.as_ref(ctx).is_shared_ambient_agent_session()
|
||||
&& matches!(
|
||||
selected_conversation,
|
||||
SelectedConversation::NewConversation | SelectedConversation::NoConversation
|
||||
)
|
||||
{
|
||||
let active_conversation_id = if FeatureFlag::AgentView.is_enabled() {
|
||||
view.as_ref(ctx)
|
||||
.agent_view_controller()
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
} else {
|
||||
view.as_ref(ctx)
|
||||
.ai_context_model()
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
};
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let has_empty_active_conversation = active_conversation_id
|
||||
.as_ref()
|
||||
.and_then(|conversation_id| history_model.as_ref(ctx).conversation(conversation_id))
|
||||
.is_some_and(|c| c.exchange_count() == 0);
|
||||
|
||||
if has_empty_active_conversation {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match selected_conversation {
|
||||
SelectedConversation::ExistingConversation(server_conversation_token) => {
|
||||
// Convert server token to local conversation ID using the AI controller
|
||||
let ai_controller = view.as_ref(ctx).ai_controller().clone();
|
||||
let conversation_id = ai_controller.update(ctx, |controller, ctx| {
|
||||
controller.find_existing_conversation_by_server_token(
|
||||
&server_conversation_token.as_uuid().to_string(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
// Update the context model with the selected conversation
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_context_model().update(ctx, |context_model, ctx| {
|
||||
// Only update if different to avoid feedback loop
|
||||
if context_model.selected_conversation_id(ctx) != Some(conversation_id) {
|
||||
context_model.set_pending_query_state_for_existing_conversation(
|
||||
conversation_id,
|
||||
AgentViewEntryOrigin::SharedSessionSelection,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
SelectedConversation::NewConversation => {
|
||||
// Start new conversation in agent view
|
||||
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_context_model().update(ctx, |context_model, ctx| {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
// Check if we're already in an empty agent view to avoid feedback loop.
|
||||
let agent_view_state = agent_view_controller.as_ref(ctx).agent_view_state();
|
||||
if let Some(conversation_id) = agent_view_state.active_conversation_id() {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let is_empty = history_model
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_none_or(|c| c.exchange_count() == 0);
|
||||
if is_empty {
|
||||
// Already in an empty agent view - no need to start another new one
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if state is already None to avoid feedback loop
|
||||
if context_model.selected_conversation_id(ctx).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
context_model.set_pending_query_state_for_new_conversation(
|
||||
AgentViewEntryOrigin::SharedSessionSelection,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
SelectedConversation::NoConversation => {
|
||||
let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone();
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_context_model().update(ctx, |context_model, ctx| {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
// Only exit if currently in agent view to avoid feedback loop
|
||||
if agent_view_controller.as_ref(ctx).is_active() {
|
||||
agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.exit_agent_view(ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For non-agent view users, we treat NoConversation the same as new conversation.
|
||||
if context_model.selected_conversation_id(ctx).is_some() {
|
||||
context_model.set_pending_query_state_for_new_conversation(
|
||||
AgentViewEntryOrigin::SharedSessionSelection,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a selected_conversation update based on the current view state.
|
||||
/// Routes to the appropriate implementation based on whether AgentView is enabled.
|
||||
/// Returns None if the update should not be sent (e.g., selected conversation has no server token yet).
|
||||
pub(crate) fn build_selected_conversation_update(
|
||||
agent_view_controller: &ModelHandle<AgentViewController>,
|
||||
context_model: &ModelHandle<BlocklistAIContextModel>,
|
||||
ctx: &mut AppContext,
|
||||
) -> Option<UniversalDeveloperInputContextUpdate> {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
build_selected_conversation_update_agent_view_enabled(
|
||||
agent_view_controller,
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
ctx,
|
||||
)
|
||||
} else {
|
||||
build_selected_conversation_update_agent_view_disabled(
|
||||
context_model,
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_selected_conversation_update_agent_view_disabled(
|
||||
ai_context_model: &ModelHandle<BlocklistAIContextModel>,
|
||||
history_model: &ModelHandle<BlocklistAIHistoryModel>,
|
||||
ctx: &mut AppContext,
|
||||
) -> Option<UniversalDeveloperInputContextUpdate> {
|
||||
let selected_conversation_id = ai_context_model.as_ref(ctx).selected_conversation_id(ctx);
|
||||
let server_token_opt: Option<ServerConversationToken> =
|
||||
selected_conversation_id.and_then(|conversation_id| {
|
||||
history_model
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|conversation| conversation.server_conversation_token().cloned())
|
||||
.and_then(|token| token.try_into().ok())
|
||||
});
|
||||
|
||||
// Only send update if starting new (None) or token is present
|
||||
let should_send = selected_conversation_id.is_none() || server_token_opt.is_some();
|
||||
if !should_send {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(UniversalDeveloperInputContextUpdate {
|
||||
selected_conversation: Some(SelectedConversation::new(server_token_opt)),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_selected_conversation_update_agent_view_enabled(
|
||||
agent_view_controller: &ModelHandle<AgentViewController>,
|
||||
history_model: &ModelHandle<BlocklistAIHistoryModel>,
|
||||
ctx: &mut AppContext,
|
||||
) -> Option<UniversalDeveloperInputContextUpdate> {
|
||||
let agent_view_state = agent_view_controller.as_ref(ctx).agent_view_state();
|
||||
|
||||
let selected_conversation = if !agent_view_state.is_active() {
|
||||
SelectedConversation::NoConversation
|
||||
} else if let Some(conversation_id) = agent_view_state.active_conversation_id() {
|
||||
let conversation = history_model.as_ref(ctx).conversation(&conversation_id);
|
||||
let server_token_opt = conversation
|
||||
.and_then(|c| c.server_conversation_token().cloned())
|
||||
.and_then(|token| token.try_into().ok());
|
||||
|
||||
if let Some(server_token) = server_token_opt {
|
||||
SelectedConversation::ExistingConversation(server_token)
|
||||
} else {
|
||||
// If the conversation has content but no token yet, skip this update. Otherwise we'd send
|
||||
// NewConversation now and ExistingConversation moments later when the token
|
||||
// arrives, causing the second update to sometimes be overwritten by an echo of the first update
|
||||
// (and leading to a weird state where the viewer sends a query and is then briefly entered into an empty agent view).
|
||||
let is_empty = conversation.is_none_or(|c| c.exchange_count() == 0);
|
||||
if is_empty {
|
||||
SelectedConversation::NewConversation
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SelectedConversation::NewConversation
|
||||
};
|
||||
|
||||
Some(UniversalDeveloperInputContextUpdate {
|
||||
selected_conversation: Some(selected_conversation),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies CLI agent session + rich-input state from the remote side.
|
||||
/// Creates/removes the session and opens/closes rich input based on
|
||||
/// the given `CLIAgentSessionState`.
|
||||
pub(crate) fn apply_cli_agent_state_update(
|
||||
weak_view_handle: &WeakViewHandle<TerminalView>,
|
||||
cli_agent_session: &CLIAgentSessionState,
|
||||
_guard: &ActiveRemoteUpdate,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(view) = weak_view_handle.upgrade(ctx) else {
|
||||
return;
|
||||
};
|
||||
let view_id = view.id();
|
||||
|
||||
match cli_agent_session {
|
||||
CLIAgentSessionState::Active {
|
||||
cli_agent,
|
||||
is_rich_input_open,
|
||||
} => {
|
||||
let agent = CLIAgent::from_serialized_name(cli_agent);
|
||||
|
||||
// Create the agent session if it does not exist.
|
||||
let already_exists = CLIAgentSessionsModel::as_ref(ctx)
|
||||
.session(view_id)
|
||||
.is_some_and(|s| s.agent == agent);
|
||||
if !already_exists {
|
||||
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
|
||||
sessions_model.set_session(
|
||||
view_id,
|
||||
CLIAgentSession {
|
||||
agent,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
listener: None,
|
||||
plugin_version: None,
|
||||
remote_host: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
// Viewer input is managed by the sync protocol,
|
||||
// not local status-change auto-toggle.
|
||||
should_auto_toggle_input: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.apply_cli_agent_footer_visibility(true, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Update the rich input state.
|
||||
let currently_open = CLIAgentSessionsModel::as_ref(ctx).is_input_open(view_id);
|
||||
if currently_open != *is_rich_input_open {
|
||||
view.update(ctx, |view, ctx| {
|
||||
if *is_rich_input_open {
|
||||
view.open_cli_agent_rich_input(
|
||||
CLIAgentInputEntrypoint::SharedSessionSync,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Other, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
CLIAgentSessionState::Inactive => {
|
||||
// Session cleanup is handled by BlockCompleted events on the
|
||||
// viewer side, so no explicit teardown is needed here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Echo-suppression for remote session-sharing context updates.
|
||||
//
|
||||
// When a participant (viewer or sharer) receives a
|
||||
// `UniversalDeveloperInputContextUpdate` from the remote side, the `apply_*`
|
||||
// helpers above update local state which fires model events. Those events are
|
||||
// observed by broadcast subscribers that would normally send the value *back*
|
||||
// over the network, creating an echo loop.
|
||||
//
|
||||
// To prevent this, each side creates a `RemoteUpdateGuard` and:
|
||||
// 1. Clones it into every broadcast subscriber, which calls
|
||||
// `guard.should_broadcast()` and skips when `false`.
|
||||
// 2. Wraps incoming `apply_*` calls with `guard.start_remote_update()`,
|
||||
// which returns an `ActiveRemoteUpdate` RAII token that suppresses
|
||||
// broadcasts for the duration of the synchronous update.
|
||||
//
|
||||
// When adding a **new** field to `UniversalDeveloperInputContextUpdate`:
|
||||
// - Check `guard.should_broadcast()` in the new broadcast subscriber.
|
||||
// - Ensure the new `apply_*` call sits inside the existing
|
||||
// `ActiveRemoteUpdate` scope in the incoming handler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared guard that tracks whether we are currently applying a remote
|
||||
/// session-sharing context update.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RemoteUpdateGuard {
|
||||
inner: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl RemoteUpdateGuard {
|
||||
/// Creates a new guard, initially not suppressing broadcasts.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
inner: Rc::new(Cell::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when a context update originated locally and should be
|
||||
/// broadcast to the remote side. Returns `false` when we are in the middle
|
||||
/// of applying a remote update (i.e. the echo should be suppressed).
|
||||
pub(crate) fn should_broadcast(&self) -> bool {
|
||||
!self.inner.get()
|
||||
}
|
||||
|
||||
/// Returns an RAII token that suppresses outgoing broadcasts until dropped.
|
||||
/// Wrap all `apply_*` calls for incoming remote updates in this so that
|
||||
/// the synchronous event dispatch sees the guard as active.
|
||||
pub(crate) fn start_remote_update(&self) -> ActiveRemoteUpdate {
|
||||
debug_assert!(
|
||||
!self.inner.get(),
|
||||
"RemoteUpdateGuard::start_remote_update called while already active"
|
||||
);
|
||||
self.inner.set(true);
|
||||
ActiveRemoteUpdate {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII token that suppresses outgoing broadcasts while held.
|
||||
pub(crate) struct ActiveRemoteUpdate {
|
||||
inner: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl Drop for ActiveRemoteUpdate {
|
||||
fn drop(&mut self) {
|
||||
self.inner.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
//! The sharer is the client that initiates the shared session.
|
||||
pub(crate) mod network;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,717 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_channel::Sender;
|
||||
use futures_util::stream::AbortHandle;
|
||||
use instant::Instant;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::{
|
||||
common::{
|
||||
ActivePrompt, OrderedTerminalEvent, OrderedTerminalEventType, ParticipantId, Selection,
|
||||
SessionId,
|
||||
},
|
||||
sharer::{DownstreamMessage, ReconnectToken, UpstreamMessage},
|
||||
};
|
||||
use warpui::{App, ModelHandle};
|
||||
use websocket::{Message, WebsocketMessage as _};
|
||||
|
||||
use crate::{
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
editor::ReplicaId,
|
||||
server::{
|
||||
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
terminal::{
|
||||
shared_session::{SharedSessionScrollbackType, MAX_BYTES_SHAREABLE},
|
||||
TerminalModel,
|
||||
},
|
||||
test_util::assert_eventually,
|
||||
};
|
||||
|
||||
use super::{Network, PtyBytesBatchStatus, Stage};
|
||||
|
||||
fn is_upstream_message_pty_bytes_read(
|
||||
message: UpstreamMessage,
|
||||
expected_event_no: usize,
|
||||
expected_bytes: Vec<u8>,
|
||||
) -> bool {
|
||||
let compressed_bytes = lz4_flex::block::compress_prepend_size(&expected_bytes);
|
||||
matches!(message, UpstreamMessage::OrderedTerminalEvent(OrderedTerminalEvent {
|
||||
event_no,
|
||||
event_type: OrderedTerminalEventType::PtyBytesRead { bytes },
|
||||
}) if event_no == expected_event_no && bytes == compressed_bytes)
|
||||
}
|
||||
|
||||
fn is_upstream_message_command_executed(
|
||||
message: &UpstreamMessage,
|
||||
expected_event_no: usize,
|
||||
) -> bool {
|
||||
matches!(message, UpstreamMessage::OrderedTerminalEvent(OrderedTerminalEvent {
|
||||
event_no,
|
||||
event_type: OrderedTerminalEventType::CommandExecutionStarted { .. },
|
||||
}) if *event_no == expected_event_no)
|
||||
}
|
||||
|
||||
fn create_network(
|
||||
app: &mut App,
|
||||
session_initialized: bool,
|
||||
) -> (ModelHandle<Network>, Sender<OrderedTerminalEventType>) {
|
||||
let (ordered_events_tx, ordered_events_rx) = async_channel::unbounded();
|
||||
let scrollback_type = SharedSessionScrollbackType::None;
|
||||
let active_prompt = ActivePrompt::default();
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
|
||||
let network = app.add_model(|ctx| {
|
||||
Network::new_for_test(
|
||||
terminal_model,
|
||||
ordered_events_rx,
|
||||
scrollback_type,
|
||||
active_prompt,
|
||||
Selection::None,
|
||||
ReplicaId::random(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
if session_initialized {
|
||||
network.update(app, |network, _| {
|
||||
network.stage = Stage::StartedSuccessfully;
|
||||
});
|
||||
}
|
||||
|
||||
(network, ordered_events_tx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_ordered_terminal_event_message_advances_event_no() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, true).0;
|
||||
|
||||
// Make sure the event no starts at 0.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert_eq!(usize::from(network.event_no), 0);
|
||||
});
|
||||
|
||||
// Try to send an ordered terminal event message to the server.
|
||||
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_ordered_terminal_event_message(event);
|
||||
});
|
||||
|
||||
// The event no should be 1 now.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert_eq!(usize::from(network.event_no), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_ordered_terminal_event_message_max_reached() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, true).0;
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
// Make sure the ws_proxy_tx is open.
|
||||
let ws_proxy_tx = network.read(&app, |network, _ctx| network.ws_proxy_tx.clone());
|
||||
assert!(!ws_proxy_tx.is_closed());
|
||||
|
||||
// Try to send an ordered terminal event that would exceed the max bytes allowed limit.
|
||||
let overflow_event = OrderedTerminalEventType::PtyBytesRead {
|
||||
bytes: "a".repeat(MAX_BYTES_SHAREABLE + 1).into(),
|
||||
};
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_ordered_terminal_event_message(overflow_event);
|
||||
});
|
||||
|
||||
// Make sure the item we put on the ws_proxy_tx was correct.
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(matches!(item.unwrap(), UpstreamMessage::EndSession { .. }));
|
||||
|
||||
// Make sure the ws_proxy_tx is closed and nothing was sent.
|
||||
assert!(ws_proxy_tx.is_closed());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_pty_read_event_while_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, true).0;
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
|
||||
accumulated: "a".into(),
|
||||
abort_handle: AbortHandle::new_pair().0,
|
||||
};
|
||||
});
|
||||
|
||||
// Try to send a PtyBytesRead message to the server.
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_pty_bytes_read_message();
|
||||
});
|
||||
|
||||
// Make sure the item we put on the ws_proxy_tx was correct.
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_pty_bytes_read(
|
||||
item.unwrap(),
|
||||
0,
|
||||
"a".into()
|
||||
));
|
||||
|
||||
// The batch status should be NotBatching now and the last_sent_at should be updated.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time ));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_pty_read_event_while_not_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let network = create_network(&mut app, true).0;
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to not batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: init_time,
|
||||
}
|
||||
});
|
||||
|
||||
// Try to send a PtyBytesRead message to the server.
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_pty_bytes_read_message();
|
||||
});
|
||||
|
||||
// Make sure we didn't try to send anything to the server..
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
|
||||
// The batch status should be unchanged.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_pty_read_event_while_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, ordered_events_tx) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
|
||||
accumulated: "a".into(),
|
||||
abort_handle: AbortHandle::new_pair().0,
|
||||
};
|
||||
});
|
||||
|
||||
// Send a PtyBytesRead event to the Network model.
|
||||
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
|
||||
ordered_events_tx
|
||||
.try_send(event)
|
||||
.expect("Can send event over ordered_events_tx");
|
||||
|
||||
// The batching status should reflect the accumulated bytes.
|
||||
assert_eventually!(
|
||||
network.read(&app, |network, _ctx| {
|
||||
matches!(&network.pty_bytes_batch_status, PtyBytesBatchStatus::Batching { accumulated, .. } if accumulated == b"aa" )
|
||||
}), "Batching status should reflect accumulated bytes"
|
||||
);
|
||||
|
||||
// Technically, we didn't start a task to send the event to the server after a timer. So let's do it manually.
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_pty_bytes_read_message();
|
||||
});
|
||||
|
||||
// Eventually, the accumulated event should be sent to the server.
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_pty_bytes_read(
|
||||
item.unwrap(),
|
||||
0,
|
||||
"aa".into()
|
||||
));
|
||||
|
||||
// The batching status should be reset.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_pty_read_event_while_not_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, ordered_events_tx) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to not batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: init_time,
|
||||
}
|
||||
});
|
||||
|
||||
// Send a PtyBytesRead event to the Network model.
|
||||
let event = OrderedTerminalEventType::PtyBytesRead { bytes: "a".into() };
|
||||
ordered_events_tx
|
||||
.try_send(event)
|
||||
.expect("Can send event over ordered_events_tx");
|
||||
|
||||
assert_eventually!(
|
||||
network.read(&app, |network, _ctx| {
|
||||
matches!(&network.pty_bytes_batch_status, PtyBytesBatchStatus::Batching { accumulated, .. } if accumulated == b"a" )
|
||||
}),
|
||||
"Batching status should be batching"
|
||||
);
|
||||
|
||||
// When the timer is done, the accumulated event should be sent to the server.
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 1,
|
||||
"Accumulated event should be sent to the server"
|
||||
);
|
||||
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_pty_bytes_read(
|
||||
item.unwrap(),
|
||||
0,
|
||||
"a".into()
|
||||
));
|
||||
|
||||
// The batching status should be reset.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_non_pty_read_event_while_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, ordered_events_tx) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
|
||||
accumulated: "a".into(),
|
||||
abort_handle: AbortHandle::new_pair().0,
|
||||
};
|
||||
});
|
||||
|
||||
// Send a non PtyBytesRead event to the Network model.
|
||||
let event = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
ordered_events_tx
|
||||
.try_send(event)
|
||||
.expect("Can send event over ordered_events_tx");
|
||||
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 2,
|
||||
"Two messages should be sent to the server; got {}",
|
||||
ws_proxy_rx.len()
|
||||
);
|
||||
|
||||
// Make sure that we flush the PtyBytesRead message first.
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_pty_bytes_read(
|
||||
item.unwrap(),
|
||||
0,
|
||||
"a".into()
|
||||
));
|
||||
|
||||
// And that the non PtyBytesRead message follows suit.
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 1));
|
||||
|
||||
// The batching status should be reset.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_non_pty_read_event_while_not_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, ordered_events_tx) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set the batch status to not batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: init_time,
|
||||
}
|
||||
});
|
||||
|
||||
// Send a non PtyBytesRead event to the Network model.
|
||||
let event = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
ordered_events_tx
|
||||
.try_send(event)
|
||||
.expect("Can send event over ordered_events_tx");
|
||||
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 1,
|
||||
"One message should be sent to the server; got {}",
|
||||
ws_proxy_rx.len()
|
||||
);
|
||||
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
|
||||
|
||||
// The batching status should be unchanged.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_duplicate_prompt_updates() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
// First prompt update should go through.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
|
||||
"test warp prompt".to_owned(),
|
||||
));
|
||||
});
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
|
||||
// Duplicate prompt updates should be ignored.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
|
||||
"test warp prompt".to_owned(),
|
||||
));
|
||||
});
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
|
||||
"test warp prompt".to_owned(),
|
||||
));
|
||||
});
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
|
||||
// Different prompt should go through.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_active_prompt_update_if_changed(ActivePrompt::WarpPrompt(
|
||||
"different warp prompt".to_owned(),
|
||||
));
|
||||
});
|
||||
assert_eq!(ws_proxy_rx.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_updates_throttled_and_duplicates_ignored() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app, true);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
// Rapid fire selection updates. Only the last should be sent up the websocket due to throttling.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
for i in 0..5 {
|
||||
network.send_presence_selection_if_changed(Selection::Blocks {
|
||||
block_ids: vec![format!("block{i}").to_string().into()],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Only the very first and the last updates should go through, but not any of the intermediate ones.
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 2,
|
||||
"Selection updates should be throttled"
|
||||
);
|
||||
|
||||
// Last sent block ID should be block4, and duplicate selection updates should be ignored.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_presence_selection_if_changed(Selection::Blocks {
|
||||
block_ids: vec!["block4".to_string().into()],
|
||||
});
|
||||
});
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 2,
|
||||
"Duplicate selection updates should be ignored"
|
||||
);
|
||||
|
||||
// Different selection update should go through.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_presence_selection_if_changed(Selection::None);
|
||||
});
|
||||
assert_eventually!(
|
||||
ws_proxy_rx.len() == 3,
|
||||
"Different selection updates should go through"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_are_buffered_before_session_initialized() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app, false);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
// The network should start in the BeforeStarted state with no events.
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::BeforeStarted));
|
||||
assert_eq!(network.unacked_terminal_events.len(), 0);
|
||||
});
|
||||
|
||||
// Try to send a message to the server.
|
||||
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
let event = OrderedTerminalEvent {
|
||||
event_no: 0,
|
||||
event_type,
|
||||
};
|
||||
let message = UpstreamMessage::OrderedTerminalEvent(event);
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_message_to_server(message)
|
||||
});
|
||||
|
||||
// The message should not be sent to the server but should instead be buffered.
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::BeforeStarted));
|
||||
assert!(is_upstream_message_command_executed(
|
||||
&UpstreamMessage::OrderedTerminalEvent(
|
||||
network.unacked_terminal_events.get(&0).unwrap().clone()
|
||||
),
|
||||
0
|
||||
));
|
||||
});
|
||||
|
||||
// Simulate receiving the SessionInitialized message from the server.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
let downstream_message = DownstreamMessage::SessionInitialized {
|
||||
session_id: SessionId::new(),
|
||||
session_secret: Default::default(),
|
||||
reconnect_token: ReconnectToken::new(),
|
||||
sharer_id: ParticipantId::new(),
|
||||
sharer_firebase_uid: "mock_firebase_uid".to_string(),
|
||||
};
|
||||
let serialized = downstream_message.to_json().unwrap();
|
||||
network.process_websocket_message(Message::new(serialized), ctx);
|
||||
});
|
||||
|
||||
// The message should be flushed to the server and the stage should be advanced.
|
||||
// We should also re-send the active prompt.
|
||||
assert_eq!(ws_proxy_rx.len(), 2);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_are_buffered_while_reconnecting() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
let (network, _) = create_network(&mut app, false);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
// The network should start in the BeforeStarted state with no events.
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::BeforeStarted));
|
||||
assert_eq!(network.unacked_terminal_events.len(), 0);
|
||||
});
|
||||
|
||||
// Simulate receiving the SessionInitialized message from the server.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
let downstream_message = DownstreamMessage::SessionInitialized {
|
||||
session_id: SessionId::new(),
|
||||
session_secret: Default::default(),
|
||||
reconnect_token: ReconnectToken::new(),
|
||||
sharer_id: ParticipantId::new(),
|
||||
sharer_firebase_uid: "mock_firebase_uid".to_string(),
|
||||
};
|
||||
let serialized = downstream_message.to_json().unwrap();
|
||||
network.process_websocket_message(Message::new(serialized), ctx);
|
||||
});
|
||||
|
||||
// We should have sent the latest prompt on connection.
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
// Simulate reconnecting to the server after server disconnects. Nothing we need to do in this test to disconnect first.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
network.reconnect_websocket(ctx);
|
||||
});
|
||||
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::Reconnecting { .. }));
|
||||
});
|
||||
|
||||
// Try to send a message to the server.
|
||||
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
let event = OrderedTerminalEvent {
|
||||
event_no: 0,
|
||||
event_type,
|
||||
};
|
||||
let message = UpstreamMessage::OrderedTerminalEvent(event);
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_message_to_server(message)
|
||||
});
|
||||
|
||||
// The message should not be sent to the server but should instead be stored.
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::Reconnecting { .. }));
|
||||
assert_eq!(network.unacked_terminal_events.len(), 1);
|
||||
assert!(is_upstream_message_command_executed(
|
||||
&UpstreamMessage::OrderedTerminalEvent(
|
||||
network.unacked_terminal_events.get(&0).unwrap().clone()
|
||||
),
|
||||
0
|
||||
));
|
||||
});
|
||||
|
||||
// Simulate receiving the SessionReconnected message from the server.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
let downstream_message = DownstreamMessage::SessionReconnected {
|
||||
last_received_event_no: None,
|
||||
participant_list: Default::default(),
|
||||
};
|
||||
let serialized = downstream_message.to_json().unwrap();
|
||||
network.process_websocket_message(Message::new(serialized), ctx);
|
||||
});
|
||||
|
||||
// The message should be flushed to the server and the stage should be advanced.
|
||||
// We should also re-send the active prompt.
|
||||
assert_eq!(ws_proxy_rx.len(), 2);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
network.read(&app, |network, _| {
|
||||
assert!(matches!(&network.stage, Stage::StartedSuccessfully));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_events_are_saved_on_send_and_removed_on_ack() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app, false);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
|
||||
// Simulate receiving the SessionInitialized message from the server.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
let downstream_message = DownstreamMessage::SessionInitialized {
|
||||
session_id: SessionId::new(),
|
||||
session_secret: Default::default(),
|
||||
reconnect_token: ReconnectToken::new(),
|
||||
sharer_id: ParticipantId::new(),
|
||||
sharer_firebase_uid: "mock_firebase_uid".to_string(),
|
||||
};
|
||||
let serialized = downstream_message.to_json().unwrap();
|
||||
network.process_websocket_message(Message::new(serialized), ctx);
|
||||
});
|
||||
|
||||
// We should have sent the latest prompt on connection.
|
||||
assert_eq!(ws_proxy_rx.len(), 1);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
matches!(item.unwrap(), UpstreamMessage::UpdateActivePrompt(_));
|
||||
|
||||
// Try to send a couple messages to the server.
|
||||
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
let event = OrderedTerminalEvent {
|
||||
event_no: 0,
|
||||
event_type,
|
||||
};
|
||||
let message = UpstreamMessage::OrderedTerminalEvent(event);
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_message_to_server(message)
|
||||
});
|
||||
let event_type = OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
};
|
||||
let event = OrderedTerminalEvent {
|
||||
event_no: 1,
|
||||
event_type,
|
||||
};
|
||||
let message = UpstreamMessage::OrderedTerminalEvent(event);
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.send_message_to_server(message)
|
||||
});
|
||||
|
||||
// The messages should be both sent and stored.
|
||||
assert_eq!(ws_proxy_rx.len(), 2);
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 0));
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(is_upstream_message_command_executed(&item.unwrap(), 1));
|
||||
network.read(&app, |network, _| {
|
||||
assert_eq!(network.unacked_terminal_events.len(), 2);
|
||||
assert!(is_upstream_message_command_executed(
|
||||
&UpstreamMessage::OrderedTerminalEvent(
|
||||
network.unacked_terminal_events.get(&0).unwrap().clone()
|
||||
),
|
||||
0
|
||||
));
|
||||
assert!(is_upstream_message_command_executed(
|
||||
&UpstreamMessage::OrderedTerminalEvent(
|
||||
network.unacked_terminal_events.get(&1).unwrap().clone()
|
||||
),
|
||||
1
|
||||
));
|
||||
});
|
||||
|
||||
// Simulate receiving the EventsProcessedAck message from the server.
|
||||
network.update(
|
||||
&mut app,
|
||||
|network, ctx: &mut warpui::ModelContext<'_, Network>| {
|
||||
let downstream_message = DownstreamMessage::EventsProcessedAck {
|
||||
latest_processed_event_no: 1,
|
||||
};
|
||||
let serialized = downstream_message.to_json().unwrap();
|
||||
network.process_websocket_message(Message::new(serialized), ctx);
|
||||
},
|
||||
);
|
||||
|
||||
// Both messages should be removed from the stored events to free up memory.
|
||||
network.read(&app, |network, _| {
|
||||
assert_eq!(network.unacked_terminal_events.len(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
use crate::ai::agent::AIAgentActionId;
|
||||
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::block::AgentInteractionMetadata;
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::common::{
|
||||
OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, WindowSize,
|
||||
};
|
||||
use std::io::{sink, Sink};
|
||||
use std::sync::Arc;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity, WeakViewHandle};
|
||||
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::ansi::{self};
|
||||
use crate::terminal::shared_session::ai_agent::decode_agent_response_event;
|
||||
use crate::terminal::shared_session::{decode_scrollback, SharedSessionStatus};
|
||||
use crate::terminal::{TerminalModel, TerminalView};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// If we end up buffering more than this many events,
|
||||
/// this is an indication that we're too far ahead and
|
||||
/// could indicate an issue.
|
||||
const TOO_MANY_BUFFERED_EVENTS: usize = 50;
|
||||
|
||||
/// The event loop is used to process a stream of events
|
||||
/// originating from the sender.
|
||||
pub struct EventLoop {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
|
||||
/// We need a reference to the view in the event loop
|
||||
/// to ensure that any events which require updating the
|
||||
/// view and model happen in lockstep. For example,
|
||||
/// resize requires updating the view and model.
|
||||
/// If we just dispatched an event, we could potentially
|
||||
/// have other [`OrderedTerminalEvent`]s race which would
|
||||
/// break the invariant of the event loop.
|
||||
#[allow(dead_code)]
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
|
||||
parser: ansi::Processor,
|
||||
|
||||
/// We use a sink as a no-op writer to swallow any writes when the ansi handler needs
|
||||
/// to write back to the PTY after reading (e.g. to identify itself).
|
||||
/// We assume that the sharer will perform these write-backs.
|
||||
sink: Sink,
|
||||
|
||||
channel_event_listener: ChannelEventListener,
|
||||
|
||||
/// The next event number we need from the server.
|
||||
next_event_no: usize,
|
||||
|
||||
/// The latest event no of the session the viewer needs to catch up to, at the time of joining.
|
||||
catching_up_to_event_no: Option<usize>,
|
||||
|
||||
/// A buffer to maintain events we receive from the server that are unordered.
|
||||
buffer: HashMap<usize, OrderedTerminalEventType>,
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
channel_event_listener: ChannelEventListener,
|
||||
window_size: WindowSize,
|
||||
scrollback: Scrollback,
|
||||
catching_up_to_event_no: Option<usize>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let scrollback_blocks = decode_scrollback(&scrollback);
|
||||
let is_alt_screen_active = scrollback.is_alt_screen_active;
|
||||
terminal_model
|
||||
.lock()
|
||||
.load_shared_session_scrollback(scrollback_blocks.as_slice(), is_alt_screen_active);
|
||||
|
||||
// When we load scrollback, we might not actually complete a block (e.g. shared session started
|
||||
// without any scrollback except active block). In this case, we want to make sure the input
|
||||
// is aware of what the latest block ID is.
|
||||
if let Some(terminal_view) = terminal_view.upgrade(ctx) {
|
||||
terminal_view.update(ctx, |terminal_view, ctx| {
|
||||
terminal_view.input().update(ctx, |input, ctx| {
|
||||
input.refresh_deferred_remote_operations(ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if catching_up_to_event_no.is_none() {
|
||||
terminal_model
|
||||
.lock()
|
||||
.set_shared_session_status(SharedSessionStatus::ActiveViewer {
|
||||
role: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut event_loop = Self {
|
||||
terminal_model,
|
||||
terminal_view,
|
||||
parser: ansi::Processor::new(),
|
||||
sink: sink(),
|
||||
channel_event_listener,
|
||||
// Eventually once we have pagination, the server might need to tell us this.
|
||||
next_event_no: 0,
|
||||
buffer: HashMap::new(),
|
||||
catching_up_to_event_no,
|
||||
};
|
||||
|
||||
// Respect the sharer's window size.
|
||||
event_loop.process_resize_event(window_size, ctx);
|
||||
|
||||
event_loop
|
||||
}
|
||||
|
||||
fn process_resize_event(&mut self, new_window_size: WindowSize, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.resize_from_sharer_update(new_window_size, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns None if we haven't received any events yet.
|
||||
pub fn last_received_event_no(&self) -> Option<usize> {
|
||||
if self.next_event_no == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(self.next_event_no - 1)
|
||||
}
|
||||
|
||||
pub fn process_ordered_terminal_event(
|
||||
&mut self,
|
||||
event: OrderedTerminalEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Add the event to the buffer.
|
||||
self.buffer.insert(event.event_no, event.event_type);
|
||||
|
||||
// If we get too far ahead, let's log a warning for better debugging.
|
||||
if self.buffer.len() >= TOO_MANY_BUFFERED_EVENTS {
|
||||
log::warn!(
|
||||
"Viewer is more than {TOO_MANY_BUFFERED_EVENTS} events ahead of next_event_no"
|
||||
);
|
||||
}
|
||||
|
||||
// Flush out as many contiguous events as we can.
|
||||
while let Some(next_event) = self.buffer.remove(&self.next_event_no) {
|
||||
match next_event {
|
||||
OrderedTerminalEventType::PtyBytesRead { bytes } => {
|
||||
let mut model = self.terminal_model.lock();
|
||||
let decompressed = lz4_flex::block::decompress_size_prepended(&bytes)
|
||||
.expect("Should be able to decompress the PtyBytesRead event");
|
||||
self.parser
|
||||
.parse_bytes(&mut *model, &decompressed, &mut self.sink);
|
||||
}
|
||||
OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id,
|
||||
ai_metadata,
|
||||
} => {
|
||||
// When a non-agent command starts, clear the loading state and input buffer.
|
||||
// We don't clear for agent commands because the viewer may be typing a follow-up.
|
||||
if ai_metadata.is_none() {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.input().update(ctx, |input, ctx| {
|
||||
input.unfreeze_and_clear_agent_input(ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have AI metadata, map the tool_call_id back to the owning conversation
|
||||
let reconstructed_ai_metadata = ai_metadata.and_then(|ai_metadata| {
|
||||
let action_id: AIAgentActionId = ai_metadata.tool_call_id.into();
|
||||
|
||||
// Try to map the action back to its owning conversation.
|
||||
let Some(conversation_id) =
|
||||
self.terminal_view.upgrade(ctx).and_then(|view| {
|
||||
view.read(ctx, |view, app| {
|
||||
let terminal_view_id = view.id();
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
|
||||
// Try to map the action back to its owning conversation.
|
||||
history
|
||||
.conversation_id_for_action(&action_id, terminal_view_id)
|
||||
// Fallback to active conversation if no exact match is found.
|
||||
.or_else(|| {
|
||||
history.active_conversation_id(terminal_view_id)
|
||||
})
|
||||
})
|
||||
})
|
||||
else {
|
||||
// If we can't find the conversation ID, we can't reconstruct the AI metadata.
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(AgentInteractionMetadata::new(
|
||||
Some(action_id),
|
||||
conversation_id,
|
||||
None,
|
||||
// If the sharer started this as an agent-monitored long-running command,
|
||||
// reflect that in the viewer's metadata so the command can be rendered as an agent long-running command.
|
||||
// Further state will be inferred from the sharer's agent events.
|
||||
ai_metadata.is_agent_monitored.then_some(
|
||||
LongRunningCommandControlState::Agent {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
},
|
||||
),
|
||||
false,
|
||||
true,
|
||||
))
|
||||
});
|
||||
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.start_command_execution_for_shared_session(
|
||||
participant_id,
|
||||
reconstructed_ai_metadata.clone(),
|
||||
);
|
||||
|
||||
// Notify the action model that the action is now executing on the sharer's side
|
||||
// This allows the viewer's UI to show the command as running rather than queued
|
||||
// (which is essential for long running commands to be expandable in the UI).
|
||||
if let Some(ai_metadata) = reconstructed_ai_metadata {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
if let Some(action_id) = ai_metadata.requested_command_action_id() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller
|
||||
.mark_action_as_remotely_executing_in_shared_session(
|
||||
action_id,
|
||||
*ai_metadata.conversation_id(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderedTerminalEventType::Resize { window_size } => {
|
||||
self.process_resize_event(window_size, ctx)
|
||||
}
|
||||
OrderedTerminalEventType::CommandExecutionFinished { .. } => (),
|
||||
OrderedTerminalEventType::AgentResponseEvent {
|
||||
response_initiator,
|
||||
response_event,
|
||||
forked_from_conversation_token,
|
||||
} => {
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled() {
|
||||
match decode_agent_response_event(&response_event) {
|
||||
Ok(resp) => {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
let event_clone = resp.clone();
|
||||
let forked_from_token = forked_from_conversation_token.clone();
|
||||
view.update(ctx, move |view, ctx| {
|
||||
view.ai_controller().update(ctx, |c, ctx| {
|
||||
// Set the participant who initiated this response
|
||||
if let Some(response_initiator) = response_initiator {
|
||||
c.set_current_response_initiator(
|
||||
response_initiator,
|
||||
);
|
||||
}
|
||||
|
||||
// For forked conversations, update the viewer's conversation
|
||||
// to use the new server token (only sent once per fork).
|
||||
if let Some(forked_from) = forked_from_token {
|
||||
c.link_forked_conversation_token(
|
||||
&forked_from,
|
||||
&event_clone,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
c.handle_shared_session_response_event(
|
||||
event_clone.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to decode agent response event: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderedTerminalEventType::AgentConversationReplayStarted => {
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.set_is_receiving_agent_conversation_replay(true);
|
||||
}
|
||||
OrderedTerminalEventType::AgentConversationReplayEnded => {
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.set_is_receiving_agent_conversation_replay(false);
|
||||
}
|
||||
}
|
||||
|
||||
if Some(self.next_event_no) == self.catching_up_to_event_no {
|
||||
if let Some(view) = self.terminal_view.upgrade(ctx) {
|
||||
// TODO (suraj): reconsider how we query the role here.
|
||||
if let Some(presence_manager) =
|
||||
view.read(ctx, |view, _| view.shared_session_presence_manager())
|
||||
{
|
||||
// Role is set to the presence manager's role to stay as up-to-date as possible.
|
||||
// This avoids a race condition if a viewer gets a new role before catching up,
|
||||
// by ensuring we're not overwritting the new role.
|
||||
if let Some(role) = presence_manager.as_ref(ctx).role() {
|
||||
self.terminal_model.lock().set_shared_session_status(
|
||||
SharedSessionStatus::ActiveViewer { role },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.channel_event_listener.send_wakeup_event();
|
||||
|
||||
self.next_event_no += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for EventLoop {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "event_loop_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,255 @@
|
||||
use crate::ai::blocklist::agent_view::AgentViewState;
|
||||
use crate::terminal::model::block::SerializedBlock;
|
||||
use crate::terminal::shared_session::tests::terminal_model_for_viewer;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::terminal::{
|
||||
event_listener::ChannelEventListener, shared_session::viewer::event_loop::EventLoop,
|
||||
};
|
||||
use crate::test_util::add_window_with_terminal;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use session_sharing_protocol::common::{
|
||||
OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, ScrollbackBlock, WindowSize,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use warpui::units::Lines;
|
||||
use warpui::{App, ViewHandle};
|
||||
|
||||
fn ordered_terminal_event_from_bytes(
|
||||
bytes: impl Into<Vec<u8>>,
|
||||
event_no: usize,
|
||||
) -> OrderedTerminalEvent {
|
||||
let compressed = lz4_flex::block::compress_prepend_size(&bytes.into());
|
||||
OrderedTerminalEvent {
|
||||
event_no,
|
||||
event_type: OrderedTerminalEventType::PtyBytesRead { bytes: compressed },
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_view(app: &mut App) -> ViewHandle<TerminalView> {
|
||||
initialize_app_for_terminal_view(app);
|
||||
add_window_with_terminal(app, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_model_is_correct() {
|
||||
App::test((), |mut app| async move {
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
|
||||
channel_event_proxy.clone(),
|
||||
)));
|
||||
|
||||
let terminal_view = terminal_view(&mut app);
|
||||
let event_loop = app.add_model(|ctx| {
|
||||
EventLoop::new(
|
||||
model.clone(),
|
||||
terminal_view.downgrade(),
|
||||
channel_event_proxy.clone(),
|
||||
WindowSize {
|
||||
num_rows: 0,
|
||||
num_cols: 0,
|
||||
},
|
||||
Scrollback {
|
||||
blocks: vec![],
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Before we receive any events, the block list only contains hidden blocks.
|
||||
assert!(model
|
||||
.lock()
|
||||
.block_list()
|
||||
.blocks()
|
||||
.iter()
|
||||
.all(|block| block.height(&AgentViewState::Inactive) == Lines::zero()));
|
||||
|
||||
// Load shared session scrollback.
|
||||
let scrollback = &[
|
||||
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
||||
SerializedBlock::new_active_block_for_test(),
|
||||
];
|
||||
{
|
||||
let mut model = model.lock();
|
||||
model.load_shared_session_scrollback(scrollback, false);
|
||||
// A hidden block, a completed scrollback block, then the active block.
|
||||
assert_eq!(model.block_list().blocks().len(), 3);
|
||||
assert_eq!(
|
||||
model.block_list().blocks()[0].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
assert_ne!(
|
||||
model.block_list().blocks()[1].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
assert_eq!(
|
||||
model.block_list().blocks()[2].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
}
|
||||
|
||||
// Write some PTY events after starting active block.
|
||||
model.lock().start_command_execution();
|
||||
event_loop.update(&mut app, |event_loop, ctx| {
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx);
|
||||
});
|
||||
|
||||
let model = model.lock();
|
||||
// After writing bytes, active block should no longer have height 0.
|
||||
assert_eq!(model.block_list().blocks().len(), 3);
|
||||
assert_eq!(
|
||||
model.block_list().blocks()[0].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
assert_ne!(
|
||||
model.block_list().blocks()[1].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
assert_ne!(
|
||||
model.block_list().blocks()[2].height(&AgentViewState::Inactive),
|
||||
Lines::zero()
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_of_order_buffering() {
|
||||
App::test((), |mut app| async move {
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
|
||||
channel_event_proxy.clone(),
|
||||
)));
|
||||
|
||||
let terminal_view = terminal_view(&mut app);
|
||||
let active_block: SerializedBlock = model.lock().block_list().active_block().into();
|
||||
let event_loop = app.add_model(|ctx| {
|
||||
EventLoop::new(
|
||||
model.clone(),
|
||||
terminal_view.downgrade(),
|
||||
channel_event_proxy.clone(),
|
||||
WindowSize {
|
||||
num_rows: 0,
|
||||
num_cols: 0,
|
||||
},
|
||||
Scrollback {
|
||||
blocks: vec![ScrollbackBlock {
|
||||
raw: serde_json::to_vec(&active_block).unwrap(),
|
||||
}],
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Simulate the real event flow: CommandExecutionStarted (event_no 0) arrives first,
|
||||
// then PTY bytes (event_no 1-3) potentially in out-of-order sequence.
|
||||
event_loop.update(&mut app, |event_loop, ctx| {
|
||||
// First: sharer sends CommandExecutionStarted when user executes a command
|
||||
event_loop.process_ordered_terminal_event(
|
||||
OrderedTerminalEvent {
|
||||
event_no: 0,
|
||||
event_type: OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Then: PTY bytes arrive (potentially out of order)
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 3), ctx);
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 2), ctx);
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 1), ctx);
|
||||
});
|
||||
|
||||
// Ensure the events were applied in the right order.
|
||||
let command_grid = model
|
||||
.lock()
|
||||
.block_list()
|
||||
.active_block()
|
||||
.command_to_string()
|
||||
.trim()
|
||||
.to_string();
|
||||
assert_eq!(command_grid, "abc");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_bytes_buffered_before_command_execution_started() {
|
||||
App::test((), |mut app| async move {
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let model = Arc::new(FairMutex::new(terminal_model_for_viewer(
|
||||
channel_event_proxy.clone(),
|
||||
)));
|
||||
|
||||
let terminal_view = terminal_view(&mut app);
|
||||
let active_block: SerializedBlock = model.lock().block_list().active_block().into();
|
||||
let event_loop = app.add_model(|ctx| {
|
||||
EventLoop::new(
|
||||
model.clone(),
|
||||
terminal_view.downgrade(),
|
||||
channel_event_proxy.clone(),
|
||||
WindowSize {
|
||||
num_rows: 0,
|
||||
num_cols: 0,
|
||||
},
|
||||
Scrollback {
|
||||
blocks: vec![ScrollbackBlock {
|
||||
raw: serde_json::to_vec(&active_block).unwrap(),
|
||||
}],
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Edge case: PTY bytes arrive BEFORE CommandExecutionStarted.
|
||||
// The event loop should buffer the PTY bytes until CommandExecutionStarted arrives,
|
||||
// then process them in order.
|
||||
event_loop.update(&mut app, |event_loop, ctx| {
|
||||
// PTY bytes arrive first (event_no 0-2, out of order)
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 2), ctx);
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx);
|
||||
|
||||
// CommandExecutionStarted arrives later (event_no 3)
|
||||
event_loop.process_ordered_terminal_event(
|
||||
OrderedTerminalEvent {
|
||||
event_no: 3,
|
||||
event_type: OrderedTerminalEventType::CommandExecutionStarted {
|
||||
participant_id: Default::default(),
|
||||
ai_metadata: None,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
// More PTY bytes arrive after CommandExecutionStarted (event_no 4)
|
||||
event_loop
|
||||
.process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 1), ctx);
|
||||
});
|
||||
|
||||
// Ensure the buffering worked correctly and bytes were applied in the right order.
|
||||
// Note: The first two bytes (0, 2) arrive before CommandExecutionStarted,
|
||||
// but since the block isn't started until event 3, they should be buffered.
|
||||
// After CommandExecutionStarted, the block is started and we process in order: 0, 1, 2.
|
||||
let command_grid = model
|
||||
.lock()
|
||||
.block_list()
|
||||
.active_block()
|
||||
.command_to_string()
|
||||
.trim()
|
||||
.to_string();
|
||||
assert_eq!(command_grid, "abc");
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use crate::terminal::HistoryEntry;
|
||||
use warpui::Entity;
|
||||
|
||||
/// Responsible for managing the history of a shared session for a viewer.
|
||||
#[derive(Default)]
|
||||
pub struct SharedSessionHistoryModel {
|
||||
entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
impl SharedSessionHistoryModel {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = &HistoryEntry> {
|
||||
self.entries.iter()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: HistoryEntry) {
|
||||
self.entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SharedSessionHistoryModel {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! The viewer is a client that joins a shared session.
|
||||
mod event_loop;
|
||||
pub(crate) mod history_model;
|
||||
mod network;
|
||||
pub(crate) mod terminal_manager;
|
||||
pub(crate) use terminal_manager::TerminalManager;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,40 @@
|
||||
use settings::Setting;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
terminal::{
|
||||
safe_mode_settings::SafeModeSettings, shared_session::SharedSessionStatus, TerminalModel,
|
||||
},
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_viewer_secret_obfuscation_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
app.update(|ctx| {
|
||||
SafeModeSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings
|
||||
.safe_mode_enabled
|
||||
.set_value(true, ctx)
|
||||
.expect("Can update safe mode setting");
|
||||
});
|
||||
});
|
||||
|
||||
let mut model = TerminalModel::mock(None, None);
|
||||
model.set_shared_session_status(SharedSessionStatus::ActiveViewer {
|
||||
role: Default::default(),
|
||||
});
|
||||
model.simulate_block("echo 1.1.1.1", "");
|
||||
for block in model.block_list().blocks() {
|
||||
assert_eq!(
|
||||
block
|
||||
.prompt_and_command_grid()
|
||||
.grid_handler()
|
||||
.num_secrets_obfuscated(),
|
||||
0
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
use async_channel::Sender;
|
||||
use async_io::Timer;
|
||||
use instant::Instant;
|
||||
use session_sharing_protocol::viewer::UpstreamMessage;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use warpui::{App, ModelHandle};
|
||||
|
||||
use crate::{
|
||||
terminal::{event_listener::ChannelEventListener, TerminalModel},
|
||||
test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view},
|
||||
};
|
||||
|
||||
use super::{Network, PtyBytesBatchStatus, Stage};
|
||||
|
||||
fn create_network(app: &mut App) -> (ModelHandle<Network>, Sender<Vec<u8>>) {
|
||||
initialize_app_for_terminal_view(app);
|
||||
let terminal_view = add_window_with_terminal(app, None).downgrade();
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
let channel_event_proxy = ChannelEventListener::new_for_test();
|
||||
let (write_to_pty_events_tx, write_to_pty_events_rx) = async_channel::unbounded();
|
||||
|
||||
let network = app.add_model(|ctx| {
|
||||
Network::new_for_test(
|
||||
channel_event_proxy,
|
||||
terminal_view,
|
||||
terminal_model,
|
||||
write_to_pty_events_rx,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
network.update(app, |network, _| {
|
||||
network.stage = Stage::JoinedSuccessfully;
|
||||
});
|
||||
|
||||
(network, write_to_pty_events_tx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_pty_write_event_advances_event_no() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app);
|
||||
|
||||
// Event number should start at 0.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert_eq!(network.write_to_pty_event_no.as_usize(), 0);
|
||||
});
|
||||
|
||||
// Try to send a write to pty event message to the server.
|
||||
network.update(&mut app, |network, ctx| {
|
||||
let abort_handle = ctx.spawn_abortable(
|
||||
Timer::after(Duration::from_millis(1)),
|
||||
move |_, _, _| {},
|
||||
|_, _| {},
|
||||
);
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::Batching {
|
||||
accumulated: "a".into(),
|
||||
abort_handle,
|
||||
};
|
||||
});
|
||||
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_write_to_pty();
|
||||
});
|
||||
|
||||
// Event number is advanced to 1.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert_eq!(network.write_to_pty_event_no.as_usize(), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_pty_write_event_while_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, tx) = create_network(&mut app);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Reset batching status.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: init_time,
|
||||
};
|
||||
});
|
||||
|
||||
// Try to send write to pty events.
|
||||
tx.try_send("a".into())
|
||||
.expect("Can send event over write_to_pty_tx");
|
||||
tx.try_send("b".into())
|
||||
.expect("Can send event over write_to_pty_tx");
|
||||
|
||||
// Ensure the accumulated event is sent to the server, and the item in ws_proxy_tx is correct.
|
||||
let item = ws_proxy_rx.recv().await;
|
||||
assert!(
|
||||
matches!(item.unwrap(), UpstreamMessage::WriteToPty { bytes, .. } if bytes == b"ab")
|
||||
);
|
||||
|
||||
// The batch status should be updated.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at > init_time));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_pty_write_event_while_not_batching() {
|
||||
App::test((), |mut app| async move {
|
||||
let (network, _) = create_network(&mut app);
|
||||
let ws_proxy_rx = network.read(&app, |network, _ctx| network.ws_proxy_rx.clone());
|
||||
let init_time = Instant::now();
|
||||
|
||||
// Set batch status to not batching.
|
||||
network.update(&mut app, |network, _ctx| {
|
||||
network.pty_bytes_batch_status = PtyBytesBatchStatus::NotBatching {
|
||||
last_sent_at: init_time,
|
||||
};
|
||||
});
|
||||
|
||||
// Try to send write to pty message to server.
|
||||
network.update(&mut app, |network, _| {
|
||||
network.send_write_to_pty();
|
||||
});
|
||||
|
||||
// Make sure we didn't try to send anything to the server.
|
||||
assert_eq!(ws_proxy_rx.len(), 0);
|
||||
|
||||
// The batch status should be unchanged.
|
||||
network.read(&app, |network, _ctx| {
|
||||
assert!(matches!(network.pty_bytes_batch_status, PtyBytesBatchStatus::NotBatching { last_sent_at } if last_sent_at == init_time));
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user