Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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()
}
}