Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user