Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ rmcp = { workspace = true, features = ["client"] }
warp_isolation_platform.workspace = true
warp_ripgrep.workspace = true
warp_managed_secrets.workspace = true
qrcode = "0.14.1"

[target.'cfg(target_os = "macos")'.dependencies]
block.workspace = true
Expand Down
52 changes: 45 additions & 7 deletions app/src/view_components/dismissible_toast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ use pathfinder_geometry::vector::vec2f;
use uuid::Uuid;
use warp_core::ui::builder::UiBuilder;
use warp_core::ui::theme::color::internal_colors;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::ChildView;
use warpui::keymap::Keystroke;
use warpui::r#async::Timer;
use warpui::{
elements::{
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, EventHandler, Flex, Hoverable, Icon, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack,
Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, EventHandler, Flex, Hoverable, Icon, Image,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable,
Stack,
},
fonts::Weight,
r#async::SpawnedFutureHandle,
Expand All @@ -27,7 +29,7 @@ use crate::{appearance::Appearance, themes::theme::Fill};

use super::action_button::ActionButton;

const TOAST_WIDTH: f32 = 464.;
pub const DISMISSIBLE_TOAST_WIDTH: f32 = 464.;
const TOAST_CORNER_RADIUS: f32 = 4.;
const TEXT_MARGIN: f32 = 16.;
const VERTICAL_PADDING: f32 = 8.;
Expand Down Expand Up @@ -321,6 +323,7 @@ pub struct DismissibleToast<A: Action + Clone> {
/// same ID, as it's likely the older ones are now out-of-date.
object_id: Option<String>,
action_button: Option<ViewHandle<ActionButton>>,
qr_code_asset_id: Option<String>,
/// Optional callback invoked when the toast body is clicked.
pub(crate) on_body_click: Option<OnBodyClickCallback<A>>,
}
Expand All @@ -339,6 +342,7 @@ impl<A: Action + Clone> DismissibleToast<A> {
close_button_hover_state: Default::default(),
object_id: Default::default(),
action_button: Default::default(),
qr_code_asset_id: Default::default(),
on_body_click: None,
}
}
Expand Down Expand Up @@ -371,6 +375,12 @@ impl<A: Action + Clone> DismissibleToast<A> {
self
}

/// Renders a QR code image below the toast body.
pub fn with_qr_code_asset_id(mut self, asset_id: String) -> Self {
self.qr_code_asset_id = Some(asset_id);
self
}

/// Sets a callback to be invoked when the toast body is clicked.
/// When set, the entire toast body becomes clickable.
pub fn with_on_body_click<F>(mut self, callback: F) -> Self
Expand Down Expand Up @@ -428,7 +438,7 @@ impl<A: Action + Clone> DismissibleToast<A> {
ConstrainedBox::new(
link.render(ui_builder, self.flavor.text_color(appearance)),
)
.with_max_width(TOAST_WIDTH / 3.)
.with_max_width(DISMISSIBLE_TOAST_WIDTH / 3.)
.finish(),
)
.with_margin_left(TEXT_MARGIN)
Expand Down Expand Up @@ -479,8 +489,36 @@ impl<A: Action + Clone> DismissibleToast<A> {
toast_container
};

let toast_content: Box<dyn Element> =
if let Some(qr_code_asset_id) = &self.qr_code_asset_id {
let toast_body = ConstrainedBox::new(toast_element)
.with_width(DISMISSIBLE_TOAST_WIDTH)
.finish();
let qr_code = ConstrainedBox::new(
Image::new(
AssetSource::Raw {
id: qr_code_asset_id.clone(),
},
CacheOption::BySize,
)
.stretch()
.finish(),
)
.with_width(DISMISSIBLE_TOAST_WIDTH)
.with_height(DISMISSIBLE_TOAST_WIDTH)
.finish();

Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(toast_body)
.with_child(Container::new(qr_code).with_margin_top(5.).finish())
.finish()
} else {
toast_element
};

let mut stack = Stack::new()
.with_child(SavePosition::new(toast_element, &self.position_id(uuid)).finish());
.with_child(SavePosition::new(toast_content, &self.position_id(uuid)).finish());

if mouse_state.is_hovered() || is_mobile {
stack.add_positioned_overlay_child(
Expand Down
82 changes: 78 additions & 4 deletions app/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ use crate::view_components::callout_bubble::{
};
use crate::view_components::{
AgentToast, AgentToastStack, DismissibleToast, DismissibleToastStack, ToastLink,
DISMISSIBLE_TOAST_WIDTH,
};
use crate::window_settings::{WindowSettings, WindowSettingsChangedEvent, ZoomLevel};
use crate::workflows::{
Expand All @@ -402,12 +403,14 @@ use futures::Future;
use itertools::Itertools;
use parking_lot::FairMutex;
use pathfinder_geometry::rect::RectF;
use qrcode::{types::Color as QrColor, QrCode};
#[cfg(feature = "local_fs")]
use repo_metadata::repositories::DetectedRepositories;
use session_sharing_protocol::common::SessionId as SharedSessionId;
use std::collections::{HashMap, HashSet};
#[cfg(feature = "local_fs")]
use std::convert::TryFrom;
use std::io::Cursor as IoCursor;
use std::time::Duration;
#[cfg(target_os = "macos")]
use std::time::{SystemTime, UNIX_EPOCH};
Expand All @@ -419,13 +422,15 @@ use warpui::fonts::Weight;
use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback};

use warp_core::user_preferences::GetUserPreferences as _;
use warpui::assets::asset_cache::AssetCache;
use warpui::clipboard::ClipboardContent;
#[cfg(target_family = "wasm")]
use warpui::elements::Percentage;
use warpui::elements::{
CacheOption, DispatchEventResult, DraggableState, DropTarget, EventHandler, Image,
MouseInBehavior, Rect,
};
use warpui::image_cache::ImageType;
use warpui::ui_components::button::{Button, ButtonVariant};
use warpui::windowing::{state::ApplicationStage, StateEvent, WindowManager};
use warpui::{elements::MouseStateHandle, fonts::Properties};
Expand Down Expand Up @@ -612,6 +617,51 @@ const NEW_SESSION_SIDECAR_POSITION_ID: &str = "new_session_sidecar";
const NEW_SESSION_SIDECAR_WIDTH: f32 = 300.;
const NEW_SESSION_SIDECAR_SEARCH_BOX_HEIGHT: f32 = 32.;
const NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING: f32 = 12.;

const QR_CODE_QUIET_ZONE_MODULES: usize = 4;

fn render_remote_control_qr_code_png(shared_session_link: &str) -> Option<Vec<u8>> {
let code = QrCode::new(shared_session_link.as_bytes()).ok()?;
let image_size = DISMISSIBLE_TOAST_WIDTH.round() as u32;
let module_count = code.width();
let modules_with_quiet_zone = module_count + (QR_CODE_QUIET_ZONE_MODULES * 2);
let pixels_per_module = ((image_size as usize) / modules_with_quiet_zone).max(1);
let qr_size = modules_with_quiet_zone * pixels_per_module;
let quiet_zone_offset = ((image_size as usize).saturating_sub(qr_size)) / 2;

let mut image =
image::RgbaImage::from_pixel(image_size, image_size, image::Rgba([255, 255, 255, 255]));
let dark_module = image::Rgba([0, 0, 0, 255]);

for y in 0..module_count {
for x in 0..module_count {
if code[(x, y)] != QrColor::Dark {
continue;
}

let start_x =
quiet_zone_offset + ((x + QR_CODE_QUIET_ZONE_MODULES) * pixels_per_module);
let start_y =
quiet_zone_offset + ((y + QR_CODE_QUIET_ZONE_MODULES) * pixels_per_module);
let end_x = (start_x + pixels_per_module).min(image_size as usize);
let end_y = (start_y + pixels_per_module).min(image_size as usize);

for pixel_y in start_y..end_y {
for pixel_x in start_x..end_x {
image.put_pixel(pixel_x as u32, pixel_y as u32, dark_module);
}
}
}
}

let mut png_bytes = Vec::new();
let mut cursor = IoCursor::new(&mut png_bytes);
image::DynamicImage::ImageRgba8(image)
.write_to(&mut cursor, image::ImageFormat::Png)
.ok()?;

Some(png_bytes)
}
const NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING: f32 = 6.;
const NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING: f32 = 16.;
const NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING: f32 = 8.;
Expand Down Expand Up @@ -4233,16 +4283,40 @@ impl Workspace {
session_id: &SharedSessionId,
ctx: &mut ViewContext<Self>,
) {
ctx.clipboard().write(ClipboardContent::plain_text(
terminal::shared_session::join_link(session_id),
));
let shared_session_link = terminal::shared_session::join_link(session_id);
ctx.clipboard()
.write(ClipboardContent::plain_text(shared_session_link.clone()));

let qr_code_asset_id =
Self::insert_remote_control_qr_code(&shared_session_link, session_id, ctx);

self.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default("Remote control link copied.".to_string());
let mut toast = DismissibleToast::default("Remote control link copied.".to_string());
if let Some(qr_code_asset_id) = qr_code_asset_id {
toast = toast.with_qr_code_asset_id(qr_code_asset_id);
}
toast_stack.add_ephemeral_toast(toast, ctx);
});
}

fn insert_remote_control_qr_code(
shared_session_link: &str,
session_id: &SharedSessionId,
ctx: &mut ViewContext<Self>,
) -> Option<String> {
let asset_id = format!("remote-control-link-qr-{session_id}");
let Some(qr_code_png) = render_remote_control_qr_code_png(shared_session_link) else {
log::warn!("Failed to generate remote control QR code");
return None;
};

AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| {
asset_cache.insert_raw_asset_bytes::<ImageType>(asset_id.clone(), &qr_code_png, ctx);
});

Some(asset_id)
}

// Returns true if the focused pane is the viewer of a shared session
pub fn is_shared_session_viewer_focused(&self, app: &AppContext) -> bool {
self.active_tab_pane_group()
Expand Down