1345 lines
55 KiB
Rust
1345 lines
55 KiB
Rust
mod docker;
|
|
pub mod parse_url_paths;
|
|
pub mod web_intent_parser;
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
pub mod browser_url_handler;
|
|
|
|
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
|
use crate::ai::agent::api::ServerConversationToken;
|
|
use crate::drive::OpenWarpDriveObjectSettings;
|
|
use crate::launch_configs::launch_config::LaunchConfig;
|
|
use crate::linear::{LinearAction, LinearIssueWork};
|
|
use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg};
|
|
use crate::server::ids::ServerId;
|
|
use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent};
|
|
use crate::util::openable_file_type::{is_file_openable_in_warp, is_markdown_file};
|
|
use crate::workspace::{Workspace, WorkspaceAction, WorkspaceRegistry};
|
|
use crate::{cloud_object::ObjectType, workspace::ToastStack};
|
|
use crate::{drive::OpenWarpDriveObjectArgs, view_components::DismissibleToast};
|
|
use crate::{features::FeatureFlag, workspace::active_terminal_in_window};
|
|
|
|
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
|
|
use crate::settings_view::{OpenTeamsSettingsModalArgs, SettingsSection};
|
|
use crate::user_config::load_launch_configs;
|
|
use crate::{
|
|
quake_mode_window_id, quake_mode_window_is_open, safe_info, send_telemetry_from_app_ctx,
|
|
ChannelState, OpenPath,
|
|
};
|
|
use anyhow::{anyhow, ensure, Result};
|
|
use itertools::Itertools;
|
|
use session_sharing_protocol::common::SessionId;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use url::Url;
|
|
use warpui::notification::UserNotification;
|
|
use warpui::{platform::TerminationMode, SingletonEntity as _, TypedActionView};
|
|
|
|
use warpui::{AppContext, EntityId, ViewHandle, WindowId};
|
|
|
|
use self::docker::open_docker_container;
|
|
|
|
const DESKTOP_REDIRECT_URI_PATH: &str = "/desktop_redirect";
|
|
|
|
/// Args for opening the MCP settings page via deeplink, with optional auto-install.
|
|
/// The `autoinstall` value is the raw query param string; it is matched case-insensitively
|
|
/// against gallery titles in `autoinstall_from_gallery`.
|
|
pub struct OpenMCPSettingsArgs {
|
|
pub autoinstall: Option<String>,
|
|
}
|
|
|
|
/// Source query parameter value indicating auth was initiated from cloud agent setup.
|
|
/// Used to skip opening settings page after GitHub auth completes.
|
|
pub const CLOUD_SETUP_SOURCE: &str = "cloud_setup";
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum UriHost {
|
|
Auth,
|
|
Team,
|
|
/// A host prefix for all actions (e.g.: new tab, new window).
|
|
Action,
|
|
/// A host prefix for all actions that involve launch configurations
|
|
Launch,
|
|
/// Supports joining shared sessions via a warp:// URI.
|
|
SharedSession,
|
|
/// Supports viewing AI conversations via a warp:// URI.
|
|
Conversation,
|
|
/// Supports WD object actions
|
|
Drive,
|
|
/// Supports opening warp's settings panel via URI
|
|
Settings,
|
|
/// A host prefix for a general-purpose home/landing page. Unlike other intent URIs, the home
|
|
/// page behavior may change over time and vary from platform to platform.
|
|
Home,
|
|
/// Actions related to MCP servers (e.g.: oauth callbacks).
|
|
Mcp,
|
|
/// Opens a new tab with the Codex model and starts a conversation.
|
|
Codex,
|
|
/// Actions triggered from Linear integrations (e.g. work on issue).
|
|
Linear,
|
|
}
|
|
|
|
impl FromStr for UriHost {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s {
|
|
"auth" => Ok(Self::Auth),
|
|
"team" => Ok(Self::Team),
|
|
"action" => Ok(Self::Action),
|
|
"launch" => Ok(Self::Launch),
|
|
"shared_session" if FeatureFlag::ViewingSharedSessions.is_enabled() => {
|
|
Ok(Self::SharedSession)
|
|
}
|
|
"conversation" => Ok(Self::Conversation),
|
|
"drive" => Ok(Self::Drive),
|
|
"settings" => Ok(Self::Settings),
|
|
"home" => Ok(Self::Home),
|
|
"mcp" => Ok(Self::Mcp),
|
|
"codex" => Ok(Self::Codex),
|
|
"linear" => Ok(Self::Linear),
|
|
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl UriHost {
|
|
fn handle(&self, primary_window_id: Option<WindowId>, url: &Url, ctx: &mut AppContext) {
|
|
// Handle host
|
|
match self {
|
|
UriHost::Auth => {
|
|
ctx.window_ids()
|
|
.collect_vec()
|
|
.into_iter()
|
|
.for_each(|window_id| {
|
|
let Some(root_view_id) = ctx.root_view_id(window_id) else {
|
|
return;
|
|
};
|
|
safe_info!(
|
|
safe: ("Dispatched auth url to window {window_id}"),
|
|
full: ("Dispatched auth url {url} to window {window_id}")
|
|
);
|
|
ctx.dispatch_action(
|
|
window_id,
|
|
&[root_view_id],
|
|
"root_view:handle_incoming_auth_url",
|
|
&url.clone(),
|
|
log::Level::Info,
|
|
);
|
|
});
|
|
}
|
|
UriHost::Team => {
|
|
match url.path_segments().into_iter().flatten().last() {
|
|
// If the last segment of the URL is "settings", open the team settings page.
|
|
Some("settings") => {
|
|
open_window_with_action(
|
|
primary_window_id,
|
|
"root_view:open_team_settings_page",
|
|
ctx,
|
|
);
|
|
}
|
|
// Otherwise default to previous behavior.
|
|
_ => {
|
|
// TODO: Parse URL to ensure the user is logged into the right account
|
|
// Shows the user the settings view of their newly joined team within the app.
|
|
open_window_with_action(
|
|
primary_window_id,
|
|
"root_view:handle_team_intent_link_action",
|
|
ctx,
|
|
);
|
|
}
|
|
};
|
|
send_telemetry_from_app_ctx!(TelemetryEvent::OpenTeamFromURI, ctx);
|
|
}
|
|
UriHost::Action => {
|
|
match Action::parse(url) {
|
|
Ok(action) => action.handle(primary_window_id, url, ctx),
|
|
Err(err) => {
|
|
log::warn!("{err}");
|
|
}
|
|
};
|
|
}
|
|
UriHost::Launch => {
|
|
if let Some(desired_config_path) = get_launch_config_path(url.path()) {
|
|
let configs = load_launch_configs(&crate::user_config::launch_configs_dir());
|
|
if let Some(config) =
|
|
find_matching_config(desired_config_path.as_str(), &configs)
|
|
{
|
|
ctx.dispatch_global_action(
|
|
"root_view:open_launch_config",
|
|
&OpenLaunchConfigArg {
|
|
launch_config: config.clone(),
|
|
ui_location: LaunchConfigUiLocation::Uri,
|
|
open_in_active_window: false,
|
|
},
|
|
)
|
|
} else {
|
|
log::warn!(
|
|
"couldn't find a matching file path for '{}'",
|
|
desired_config_path.as_str()
|
|
);
|
|
}
|
|
} else {
|
|
log::warn!("couldn't turn launch link '{}' into path", url.path());
|
|
}
|
|
}
|
|
UriHost::SharedSession => {
|
|
// We expect the uri to have the ID of the session to join as the last segment.
|
|
// e.g. warp://shared_session/{id}
|
|
let session_id = url
|
|
.path_segments()
|
|
.into_iter()
|
|
.flatten()
|
|
.last()
|
|
.and_then(|id| SessionId::from_str(id).ok());
|
|
if let Some(session_id) = session_id {
|
|
// If there's an existing window, join the session inc a new tab. Otherwise, open a new window.
|
|
match primary_window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
}) {
|
|
Some((primary_window_id, root_view_id)) => {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:join_shared_session_in_existing_window",
|
|
&session_id,
|
|
log::Level::Info,
|
|
);
|
|
}
|
|
None => {
|
|
ctx.dispatch_global_action("root_view:join_shared_session", &session_id)
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Failed to join shared session with uri={url}");
|
|
}
|
|
}
|
|
UriHost::Conversation => {
|
|
// We expect the uri to have the conversation ID as the last segment.
|
|
// e.g. warp://conversation/{conversation_id}
|
|
let conversation_id: Option<ServerConversationToken> = url
|
|
.path_segments()
|
|
.into_iter()
|
|
.flatten()
|
|
.last()
|
|
.map(|s| ServerConversationToken::new(s.to_owned()));
|
|
|
|
if let Some(conversation_id) = conversation_id {
|
|
// If there's an existing window, open the conversation in a new tab. Otherwise, open a new window.
|
|
match primary_window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
}) {
|
|
Some((primary_window_id, root_view_id)) => {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:open_cloud_conversation_in_existing_window",
|
|
&conversation_id,
|
|
log::Level::Info,
|
|
);
|
|
}
|
|
None => ctx.dispatch_global_action(
|
|
"root_view:open_conversation_viewer",
|
|
&conversation_id,
|
|
),
|
|
}
|
|
} else {
|
|
log::warn!("Failed to open conversation with uri={url}");
|
|
}
|
|
}
|
|
UriHost::Drive => {
|
|
// We expect the uri to have the ID of the object we are trying to open and the object_type.
|
|
// e.g. warp://drive/{object_type}?id={UID}
|
|
// For folder links, we expect an additional query parameter primary_object_id which refers to the id object
|
|
// that should be opened
|
|
// When the user is directed here via the request access flow, we expect an additional query parameter invitee_email
|
|
// If this paramter is present, we will open the sharing dialog with the email filled in.
|
|
let object_type = url
|
|
.path_segments()
|
|
.into_iter()
|
|
.flatten()
|
|
.last()
|
|
.and_then(|object_type| ObjectType::from_str(object_type).ok());
|
|
|
|
let query_string: HashMap<_, _> = url.query_pairs().collect();
|
|
let object_server_id: Option<ServerId> =
|
|
query_string.get("id").map(ServerId::from_string_lossy);
|
|
|
|
let focused_folder_id: Option<ServerId> = query_string
|
|
.get("focused_folder_id")
|
|
.map(ServerId::from_string_lossy);
|
|
|
|
let invitee_email: Option<String> =
|
|
query_string.get("invitee_email").map(|s| s.to_string());
|
|
|
|
if let Some((object_type, server_id)) = object_type.zip(object_server_id) {
|
|
let primary_window_and_view = primary_window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
});
|
|
let args = OpenWarpDriveObjectArgs {
|
|
object_type,
|
|
server_id,
|
|
settings: OpenWarpDriveObjectSettings {
|
|
focused_folder_id,
|
|
invitee_email,
|
|
},
|
|
};
|
|
// If there's an existing window, open the object in that window, otherwise open a new window
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
// `args` may contain user-identifiable fields
|
|
// (e.g. `invitee_email`), so avoid writing the full
|
|
// debug representation to `warp.log` on non-dogfood
|
|
// release channels.
|
|
safe_info!(
|
|
safe: (
|
|
"Opening drive object in existing window: object_type={:?} server_id={}",
|
|
args.object_type, args.server_id,
|
|
),
|
|
full: ("Opening drive object in existing window: {args:?}")
|
|
);
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:open_drive_object_existing_window",
|
|
&args,
|
|
log::Level::Info,
|
|
);
|
|
} else {
|
|
ctx.dispatch_global_action("root_view:open_drive_object_new_window", &args)
|
|
}
|
|
} else {
|
|
log::warn!("Failed to open drive object with uri={url}");
|
|
}
|
|
}
|
|
UriHost::Settings => {
|
|
// We support opening different settings pages through URI:
|
|
// - warp://settings/teams?invite={email} - opens team settings with invite modal
|
|
// - warp://settings/billing_and_usage - opens billing and usage settings page
|
|
// - warp://settings/environments - opens environments settings page
|
|
// - warp://settings/mcp - opens MCP servers settings page
|
|
// - warp://settings/platform - opens platform settings page
|
|
// - warp://settings/appearance - opens appearance settings page (themes, fonts, etc.)
|
|
let settings_sub_page: Option<String> = url
|
|
.path_segments()
|
|
.into_iter()
|
|
.flatten()
|
|
.last()
|
|
.map(|s| s.to_string());
|
|
let query_string: HashMap<_, _> = url.query_pairs().collect();
|
|
|
|
if let Some(settings_sub_page) = settings_sub_page {
|
|
match settings_sub_page.as_str() {
|
|
"teams" => {
|
|
let invite_email = query_string.get("invite").map(|s| s.to_string());
|
|
let args = OpenTeamsSettingsModalArgs { invite_email };
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_team_settings_with_email_invite_in_existing_window",
|
|
"root_view:open_team_settings_with_email_invite_in_new_window",
|
|
&args,
|
|
ctx,
|
|
);
|
|
}
|
|
"billing_and_usage" => {
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_settings_page_in_existing_window",
|
|
"root_view:open_settings_page_in_new_window",
|
|
&SettingsSection::BillingAndUsage,
|
|
ctx,
|
|
);
|
|
}
|
|
"environments" => {
|
|
// Notify that GitHub auth completed so views can refresh
|
|
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
|
|
notifier.notify_auth_completed(ctx);
|
|
});
|
|
|
|
// Open settings page unless auth was initiated from cloud setup
|
|
// (cloud setup users should stay on their current page)
|
|
let source = query_string.get("source").map(|s| s.as_ref());
|
|
let skip_settings = source == Some(CLOUD_SETUP_SOURCE);
|
|
if !skip_settings {
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_settings_page_in_existing_window",
|
|
"root_view:open_settings_page_in_new_window",
|
|
&SettingsSection::CloudEnvironments,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
"mcp" => {
|
|
// warp://settings/mcp?autoinstall=<name> auto-installs a gallery MCP server.
|
|
// The value is matched case-insensitively against gallery titles.
|
|
let autoinstall =
|
|
query_string.get("autoinstall").map(|v| v.to_string());
|
|
let args = OpenMCPSettingsArgs { autoinstall };
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_mcp_settings_in_existing_window",
|
|
"root_view:open_mcp_settings_in_new_window",
|
|
&args,
|
|
ctx,
|
|
);
|
|
}
|
|
"platform" => {
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_settings_page_in_existing_window",
|
|
"root_view:open_settings_page_in_new_window",
|
|
&SettingsSection::OzCloudAPIKeys,
|
|
ctx,
|
|
);
|
|
}
|
|
"appearance" => {
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_settings_page_in_existing_window",
|
|
"root_view:open_settings_page_in_new_window",
|
|
&SettingsSection::Appearance,
|
|
ctx,
|
|
);
|
|
}
|
|
_ => {
|
|
log::warn!("Failed to open settings pane with uri={url}");
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Failed to open settings pane with uri={url}");
|
|
}
|
|
}
|
|
UriHost::Home => {
|
|
ctx.dispatch_global_action("root_view::open_new", &());
|
|
}
|
|
UriHost::Mcp => {
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
let result = crate::ai::mcp::TemplatableMCPServerManager::handle(ctx)
|
|
.update(ctx, |manager, _ctx| manager.handle_oauth_callback(url));
|
|
if let Err(e) = result {
|
|
log::error!("Failed to handle MCP OAuth callback: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
UriHost::Codex => {
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_codex_in_existing_window",
|
|
"root_view:open_codex_in_new_window",
|
|
&(),
|
|
ctx,
|
|
);
|
|
}
|
|
UriHost::Linear => match LinearAction::parse(url) {
|
|
Ok(LinearAction::WorkOnIssue) => {
|
|
let args = LinearIssueWork::from_url(url);
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_linear_issue_work_in_existing_window",
|
|
"root_view:open_linear_issue_work_in_new_window",
|
|
&args,
|
|
ctx,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
log::warn!("{err}");
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// When handling this URI action, determine which window(s) should be focused.
|
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
fn window_behavior_hint(&self) -> WindowBehaviorHint {
|
|
use WindowBehaviorHint as W;
|
|
match self {
|
|
Self::Auth => W::ShowPrimaryWindow(WindowActivationFallbackBehavior::NewWindow {
|
|
replace_existing: true,
|
|
}),
|
|
Self::Team | Self::Drive | Self::Settings => W::default(),
|
|
// These URLs always open new windows.
|
|
Self::Launch | Self::SharedSession | Self::Conversation | Self::Home => W::Nothing,
|
|
// This will actually be handled by [`Action::window_behavior_hint`].
|
|
Self::Action => W::Nothing,
|
|
// TODO(vorporeal): probably want to focus the window with the MCP pane open
|
|
Self::Mcp => W::Nothing,
|
|
// Codex opens a new tab with AI mode, use default behavior
|
|
Self::Codex => W::default(),
|
|
// Linear deeplink opens a new tab with agent view
|
|
Self::Linear => W::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This determines which windows, if any, will become visible on handling a URI. This is a "hint"
|
|
/// because it is platform-dependent, and not all platforms can conform. For example, MacOS
|
|
/// automatically shows the frontmost window, and so the Nothing variant of this is impossible on
|
|
/// MacOS.
|
|
#[derive(Clone, Debug)]
|
|
enum WindowBehaviorHint {
|
|
/// Determined by the [`get_primary_window`] function.
|
|
ShowPrimaryWindow(WindowActivationFallbackBehavior),
|
|
Nothing,
|
|
}
|
|
|
|
impl Default for WindowBehaviorHint {
|
|
fn default() -> Self {
|
|
Self::ShowPrimaryWindow(WindowActivationFallbackBehavior::NewWindow {
|
|
replace_existing: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl WindowBehaviorHint {
|
|
/// Perform the desired window focus behavior for the URI being handled. This may change the
|
|
/// "primary window" if a new one has to be created. Return the new primary WindowId.
|
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
fn resolve(
|
|
self,
|
|
primary_window_id: Option<WindowId>,
|
|
ctx: &mut AppContext,
|
|
) -> Option<WindowId> {
|
|
match self {
|
|
Self::ShowPrimaryWindow(fallback_behavior) => {
|
|
if let Some(window_id) = primary_window_id {
|
|
match ctx.windows().windowing_system() {
|
|
Some(windowing_system)
|
|
if windowing_system.allows_programmatic_window_activation() =>
|
|
{
|
|
ctx.windows().show_window_and_focus_app(window_id);
|
|
}
|
|
_ => {
|
|
return fallback_behavior.resolve(window_id, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Self::Nothing => {}
|
|
};
|
|
primary_window_id
|
|
}
|
|
}
|
|
|
|
/// If we're in an environment where we can't fulfill [`WindowBehaviorHint`], and the OS default
|
|
/// behavior isn't acceptable/reliable, e.g. Wayland doesn't allow windows to programmatically show
|
|
/// themselves, try this fallback behavior instead.
|
|
#[derive(Clone, Debug)]
|
|
enum WindowActivationFallbackBehavior {
|
|
/// If the primary window picked to handle the URL is not the active one, send a native push
|
|
/// notification.
|
|
Notify { title: String, description: String },
|
|
/// Create a new window to handle the URI.
|
|
NewWindow {
|
|
/// Close the former "primary window" as determined by [`get_primary_window`]. This should
|
|
/// generally default to `false` to avoid closing a window with information that the user
|
|
/// may still want. One exception is the Auth route where the old window just showed the
|
|
/// auth page.
|
|
replace_existing: bool,
|
|
},
|
|
}
|
|
|
|
impl WindowActivationFallbackBehavior {
|
|
/// Perform the desired window fallback behavior for the URI being handled. This may change the
|
|
/// "primary window" if a new one has to be created. Return the new primary WindowId.
|
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
fn resolve(self, primary_window_id: WindowId, ctx: &mut AppContext) -> Option<WindowId> {
|
|
match self {
|
|
WindowActivationFallbackBehavior::Notify { title, description } => {
|
|
if ctx
|
|
.windows()
|
|
.active_window()
|
|
.is_some_and(|active_window| active_window == primary_window_id)
|
|
{
|
|
return Some(primary_window_id);
|
|
}
|
|
if let Some(view_handle) = ctx
|
|
.views_of_type::<Workspace>(primary_window_id)
|
|
.filter(|views| !views.is_empty())
|
|
.map(|mut views| views.swap_remove(0))
|
|
{
|
|
view_handle.update(ctx, |_, ctx| {
|
|
ctx.send_desktop_notification(
|
|
UserNotification::new(title, description, None),
|
|
|_, err, ctx| {
|
|
log::warn!(
|
|
"Error showing URL intent notification on {:?}: {err:?}",
|
|
ctx.window_id()
|
|
)
|
|
},
|
|
);
|
|
});
|
|
}
|
|
Some(primary_window_id)
|
|
}
|
|
WindowActivationFallbackBehavior::NewWindow { replace_existing } => {
|
|
let new_window_id = open_new_window_get_handles(None, ctx).0;
|
|
if replace_existing {
|
|
ctx.windows()
|
|
.close_window(primary_window_id, TerminationMode::Cancellable);
|
|
}
|
|
Some(new_window_id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Turn the launch config URL into a filename.
|
|
/// "/hello%20world" --> "hello world"
|
|
fn get_launch_config_path(path: &str) -> Option<String> {
|
|
// Remove the leading slash before the filename.
|
|
let (_, config_path) = path.split_once('/')?;
|
|
|
|
// URL-decode the filename to recover spaces and
|
|
// other non-URL-friendly characters
|
|
let decoded = serde_urlencoded::from_str::<Vec<(String, String)>>(config_path).ok()?;
|
|
|
|
// serde_urlencoded::from_str tries to find a vector key-value pairs,
|
|
// so we'll take the first tuple in the vector...
|
|
let decoded_config_name = decoded.first()?;
|
|
|
|
// ... and read the first member of the tuple.
|
|
let validated_path = validate_launch_config_path(decoded_config_name.0.as_str())?;
|
|
|
|
// Finally, return the validated path.
|
|
Some(validated_path.to_string())
|
|
}
|
|
|
|
/// Remove file extension, which consists of the last '.' in the filename
|
|
/// and whatever characters follow it.
|
|
fn remove_extension(full_path: &str) -> Option<&str> {
|
|
let (no_extension, _) = full_path.rsplit_once('.')?;
|
|
Some(no_extension)
|
|
}
|
|
|
|
/// Ensure that a path is relative and doesn't contain '/../',
|
|
/// to prevent launch config links from escaping the launch config directory.
|
|
fn validate_launch_config_path(path: &str) -> Option<&str> {
|
|
if path.starts_with('/')
|
|
|| path.starts_with("../")
|
|
|| path.contains("/../")
|
|
|| path.ends_with("/..")
|
|
{
|
|
None
|
|
} else {
|
|
Some(path)
|
|
}
|
|
}
|
|
|
|
/// Given a config path, find a matching launch config file
|
|
fn find_matching_config<'a>(
|
|
target_path: &str,
|
|
configs: &'a [LaunchConfig],
|
|
) -> Option<&'a LaunchConfig> {
|
|
// first, try to match the exact filename.
|
|
if let Some(matched_config) = find_matching_config_name(target_path, configs) {
|
|
return Some(matched_config);
|
|
}
|
|
|
|
// next, try to match the filename without the extension
|
|
let no_extension = remove_extension(target_path)?;
|
|
find_matching_config_name(no_extension, configs)
|
|
}
|
|
|
|
/// Case-insensitive matching on the config's name
|
|
/// (field in the YAML file).
|
|
fn find_matching_config_name<'a>(
|
|
target_name: &str,
|
|
configs: &'a [LaunchConfig],
|
|
) -> Option<&'a LaunchConfig> {
|
|
let target_name_lower = target_name.to_lowercase();
|
|
configs
|
|
.iter()
|
|
.find(|&config| config.name.to_lowercase() == target_name_lower)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum Action {
|
|
NewTab,
|
|
NewWindow,
|
|
Docker,
|
|
OpenRepo,
|
|
CloudAgentSetup,
|
|
NewCloudAgentConversation,
|
|
NewAgentConversation,
|
|
CreateEnvironment { repos: Vec<String> },
|
|
FocusCloudMode,
|
|
}
|
|
|
|
impl Action {
|
|
fn parse(url: &Url) -> Result<Self> {
|
|
match url.path() {
|
|
"/new_tab" => Ok(Self::NewTab),
|
|
"/new_window" => Ok(Self::NewWindow),
|
|
"/docker/open_subshell" => Ok(Self::Docker),
|
|
"/open-repo" => Ok(Self::OpenRepo),
|
|
"/cloud_agent_setup" => Ok(Self::CloudAgentSetup),
|
|
"/new_cloud_agent_conversation" => Ok(Self::NewCloudAgentConversation),
|
|
"/new_agent_conversation" => Ok(Self::NewAgentConversation),
|
|
"/create_environment" => {
|
|
let repos = url
|
|
.query_pairs()
|
|
.filter_map(|(k, v)| (k == "repo").then(|| v.into_owned()))
|
|
.collect::<Vec<_>>();
|
|
|
|
Ok(Self::CreateEnvironment { repos })
|
|
}
|
|
"/focus_cloud_mode" => Ok(Self::FocusCloudMode),
|
|
_ => Err(anyhow!(
|
|
"Received \"action\" intent with unexpected action: {}",
|
|
url.path()
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn handle(&self, primary_window_id: Option<WindowId>, url: &Url, ctx: &mut AppContext) {
|
|
#[cfg(target_os = "linux")]
|
|
let primary_window_id = self.window_behavior_hint().resolve(primary_window_id, ctx);
|
|
match self {
|
|
Self::NewTab | Self::NewWindow => {
|
|
let window_id = if let Self::NewTab = self {
|
|
primary_window_id
|
|
} else {
|
|
None
|
|
};
|
|
let Some(Ok(path)) = url
|
|
.query_pairs()
|
|
.find(|(k, _v)| k == "path")
|
|
.map(|(_, path)| PathBuf::from_str(&path))
|
|
else {
|
|
log::warn!("Could not parse path to open a new tab/window");
|
|
return;
|
|
};
|
|
open_file(window_id, path, ctx);
|
|
}
|
|
Action::Docker => {
|
|
if let Err(err) = open_docker_container(url, ctx) {
|
|
if let Some(window_id) = primary_window_id {
|
|
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
|
let toast =
|
|
DismissibleToast::error("Custom URI is invalid.".to_owned());
|
|
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
|
});
|
|
}
|
|
|
|
log::warn!("error opening docker container: {err}");
|
|
}
|
|
}
|
|
Action::OpenRepo => {
|
|
let window_id =
|
|
primary_window_id.or_else(|| Some(open_new_window_get_handles(None, ctx).0));
|
|
|
|
let Some(window_id) = window_id else {
|
|
log::warn!("unable to determine window for open repo action");
|
|
return;
|
|
};
|
|
|
|
let Some(mut workspaces) = ctx.views_of_type::<Workspace>(window_id) else {
|
|
log::warn!("no workspace found in window {window_id} for open repo action");
|
|
return;
|
|
};
|
|
|
|
if let Some(workspace) = workspaces.pop() {
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
workspace
|
|
.handle_action(&WorkspaceAction::OpenRepository { path: None }, ctx);
|
|
});
|
|
} else {
|
|
log::warn!("no workspace views in window {window_id} for open repo action");
|
|
}
|
|
}
|
|
Action::CloudAgentSetup => {
|
|
let window_id =
|
|
primary_window_id.or_else(|| Some(open_new_window_get_handles(None, ctx).0));
|
|
|
|
let Some(window_id) = window_id else {
|
|
log::warn!("unable to determine window for cloud agent setup action");
|
|
return;
|
|
};
|
|
|
|
let Some(mut workspaces) = ctx.views_of_type::<Workspace>(window_id) else {
|
|
log::warn!(
|
|
"no workspace found in window {window_id} for cloud agent setup action"
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Some(workspace) = workspaces.pop() {
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
workspace.handle_action(&WorkspaceAction::OpenCloudAgentSetupGuide, ctx);
|
|
});
|
|
} else {
|
|
log::warn!(
|
|
"no workspace views in window {window_id} for cloud agent setup action"
|
|
);
|
|
}
|
|
}
|
|
Action::NewCloudAgentConversation => {
|
|
let window_id =
|
|
primary_window_id.or_else(|| Some(open_new_window_get_handles(None, ctx).0));
|
|
|
|
let Some(window_id) = window_id else {
|
|
log::warn!(
|
|
"unable to determine window for new cloud agent conversation action"
|
|
);
|
|
return;
|
|
};
|
|
|
|
let Some(mut workspaces) = ctx.views_of_type::<Workspace>(window_id) else {
|
|
log::warn!(
|
|
"no workspace found in window {window_id} for new cloud agent conversation action"
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Some(workspace) = workspaces.pop() {
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
workspace.handle_action(&WorkspaceAction::AddAmbientAgentTab, ctx);
|
|
});
|
|
} else {
|
|
log::warn!(
|
|
"no workspace views in window {window_id} for new cloud agent conversation action"
|
|
);
|
|
}
|
|
}
|
|
Action::NewAgentConversation => {
|
|
let window_id =
|
|
primary_window_id.or_else(|| Some(open_new_window_get_handles(None, ctx).0));
|
|
|
|
let Some(window_id) = window_id else {
|
|
log::warn!("unable to determine window for new agent conversation action");
|
|
return;
|
|
};
|
|
|
|
let Some(workspace) = WorkspaceRegistry::as_ref(ctx).get(window_id, ctx) else {
|
|
log::warn!(
|
|
"no workspace found in window {window_id} for new agent conversation action"
|
|
);
|
|
return;
|
|
};
|
|
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
workspace.handle_action(&WorkspaceAction::AddAgentTab, ctx);
|
|
});
|
|
}
|
|
Action::CreateEnvironment { repos } => {
|
|
use crate::root_view::CreateEnvironmentArg;
|
|
|
|
let arg = CreateEnvironmentArg {
|
|
repos: repos.clone(),
|
|
};
|
|
|
|
let primary_window_and_view = primary_window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
});
|
|
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:create_environment_in_existing_window",
|
|
&arg,
|
|
log::Level::Info,
|
|
);
|
|
} else {
|
|
ctx.dispatch_global_action("root_view:create_environment", &arg);
|
|
}
|
|
}
|
|
Action::FocusCloudMode => {
|
|
// Notify that GitHub auth completed so views can refresh
|
|
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
|
|
notifier.notify_auth_completed(ctx);
|
|
});
|
|
|
|
let active_agent_views = ActiveAgentViewsModel::as_ref(ctx);
|
|
let focused_conversation = primary_window_id
|
|
.and_then(|wid| active_agent_views.get_focused_conversation(wid));
|
|
let mut terminal_view_id = match focused_conversation {
|
|
Some(ConversationOrTaskId::TaskId(task_id)) => {
|
|
active_agent_views.get_terminal_view_id_for_ambient_task(task_id)
|
|
}
|
|
Some(ConversationOrTaskId::ConversationId(conversation_id)) => {
|
|
active_agent_views
|
|
.get_terminal_view_id_for_conversation(conversation_id, ctx)
|
|
}
|
|
None => None,
|
|
};
|
|
if terminal_view_id.is_none() {
|
|
terminal_view_id = find_cloud_mode_terminal_view_id(primary_window_id, ctx);
|
|
}
|
|
if terminal_view_id.is_none() {
|
|
terminal_view_id = active_agent_views.get_last_focused_terminal_id();
|
|
}
|
|
if terminal_view_id.is_none() {
|
|
terminal_view_id = primary_window_id
|
|
.and_then(|window_id| active_terminal_view_id_in_window(window_id, ctx));
|
|
}
|
|
|
|
if let Some(terminal_view_id) = terminal_view_id {
|
|
if let Some((window_id, workspace)) =
|
|
find_workspace_for_terminal_view(terminal_view_id, ctx)
|
|
{
|
|
ctx.windows().show_window_and_focus_app(window_id);
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
workspace.handle_action(
|
|
&WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id },
|
|
ctx,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
dispatch_action_in_new_or_existing_window(
|
|
primary_window_id,
|
|
"root_view:open_settings_page_in_existing_window",
|
|
"root_view:open_settings_page_in_new_window",
|
|
&SettingsSection::CloudEnvironments,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// When handling this URI action, determine which window(s) should be focused.
|
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
fn window_behavior_hint(&self) -> WindowBehaviorHint {
|
|
use WindowBehaviorHint as W;
|
|
match self {
|
|
Self::Docker
|
|
| Self::CreateEnvironment { .. }
|
|
| Self::OpenRepo
|
|
| Self::CloudAgentSetup
|
|
| Self::NewCloudAgentConversation
|
|
| Self::NewAgentConversation
|
|
| Self::FocusCloudMode => W::default(),
|
|
Self::NewTab => W::ShowPrimaryWindow(WindowActivationFallbackBehavior::Notify {
|
|
title: "New tab created".to_owned(),
|
|
description: "Go to Warp to see your new tab.".to_owned(),
|
|
}),
|
|
Self::NewWindow => W::Nothing,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles all incoming urls. These urls are file urls, auth urls for login,
|
|
/// and team urls for opening team settings.
|
|
pub fn handle_incoming_uri(url: &Url, ctx: &mut AppContext) {
|
|
// Non-dogfood builds must never log the full URL here: URLs routed to this
|
|
// handler can carry secrets in their query string (for example, the
|
|
// Firebase `refresh_token` on `warp://auth/desktop_redirect?...`). Log
|
|
// only the non-sensitive components (scheme, host, path) on release
|
|
// channels; dogfood builds retain the full URL for local debugging.
|
|
safe_info!(
|
|
safe: ("received url {}", safe_url_log_fields(url)),
|
|
full: ("received url {:?}", &url)
|
|
);
|
|
|
|
// Pick the window that should be handling the URI. This has some
|
|
// additional logic to handle the hotkey window and there being no
|
|
// currently-active window.
|
|
let primary_window_id = get_primary_window(ctx.windows().frontmost_window_id(), ctx);
|
|
|
|
// If we're running on a platform where we can spawn local TTYs,
|
|
// check if this is a file:// URL and if so, spawn a new session
|
|
// with an initial working directory based on the provided path.
|
|
#[cfg(feature = "local_tty")]
|
|
if url.scheme() == "file" {
|
|
if let Ok(path) = url.to_file_path() {
|
|
open_file(primary_window_id, path, ctx);
|
|
}
|
|
return;
|
|
}
|
|
|
|
match validate_custom_uri(url) {
|
|
Ok(host) => {
|
|
#[cfg(any(target_os = "linux", windows))]
|
|
let primary_window_id = host.window_behavior_hint().resolve(primary_window_id, ctx);
|
|
host.handle(primary_window_id, url, ctx);
|
|
}
|
|
Err(e) => {
|
|
if let Some(window_id) = primary_window_id {
|
|
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
|
let toast = DismissibleToast::error(format!("Custom URI is invalid: {e:?}"));
|
|
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
|
});
|
|
}
|
|
log::warn!("Custom URI is invalid: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gets the primary window ID, and returns `None` if it does not exist.
|
|
/// A primary window is the foregrounded window, or one of the inactive non-quake windows.
|
|
/// A closed quake window is not counted.
|
|
fn get_primary_window(
|
|
active_window_id: Option<WindowId>,
|
|
ctx: &mut AppContext,
|
|
) -> Option<WindowId> {
|
|
// Return quake mode window if it's open
|
|
if let Some(window_id) = quake_mode_window_id()
|
|
.filter(|window_id| quake_mode_window_is_open() && ctx.is_window_open(*window_id))
|
|
{
|
|
return Some(window_id);
|
|
}
|
|
|
|
// Otherwise, return active window
|
|
if let Some(window_id) = active_window_id {
|
|
return Some(window_id);
|
|
}
|
|
|
|
let mut non_quake_mode_windows = ctx
|
|
.window_ids()
|
|
.filter(|window_id| Some(*window_id) != quake_mode_window_id());
|
|
|
|
// There's no active window, return first non-quake mode window or None if none exist.
|
|
non_quake_mode_windows.next()
|
|
}
|
|
|
|
/// Handle an incoming `file://` URL.
|
|
/// * Markdown files are opened as notebook panes.
|
|
/// * For directories, open a new session at the directory path.
|
|
/// * For other files, open a new session at the parent directory path, then possibly execute the
|
|
/// file.
|
|
fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
|
|
let primary_window_and_view = window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
});
|
|
|
|
if is_markdown_file(&path) {
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:add_file_pane",
|
|
&path,
|
|
log::Level::Info,
|
|
);
|
|
} else {
|
|
ctx.dispatch_global_action("root_view:open_new_with_file_notebook", &path);
|
|
}
|
|
} else if path.is_file() && is_file_openable_in_warp(&path).is_some() {
|
|
#[cfg(feature = "local_fs")]
|
|
{
|
|
use crate::code::editor_management::CodeSource;
|
|
use crate::root_view::{open_new_with_workspace_source, NewWorkspaceSource};
|
|
use crate::util::{
|
|
file::external_editor::EditorSettings,
|
|
openable_file_type::resolve_file_target_to_open_in_warp,
|
|
};
|
|
|
|
// Open text/code files in Warp's code editor, respecting the user's layout preference.
|
|
let editor_settings = EditorSettings::as_ref(ctx);
|
|
let target = resolve_file_target_to_open_in_warp(&path, editor_settings, None);
|
|
|
|
let window_id = if let Some((wid, _)) = primary_window_and_view {
|
|
wid
|
|
} else {
|
|
open_new_with_workspace_source(
|
|
NewWorkspaceSource::Session {
|
|
options: Box::default(),
|
|
},
|
|
ctx,
|
|
)
|
|
.0
|
|
};
|
|
|
|
ctx.windows().show_window_and_focus_app(window_id);
|
|
|
|
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
|
|
if let Some(workspace) = workspaces.into_iter().next() {
|
|
workspace.update(ctx, |workspace, ctx| {
|
|
let source = CodeSource::Finder { path: path.clone() };
|
|
workspace.open_file_with_target(path, target, None, source, ctx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let directory_path = if path.is_file() {
|
|
match path.parent() {
|
|
Some(parent) => parent.to_path_buf(),
|
|
None => PathBuf::new(),
|
|
}
|
|
} else {
|
|
path.clone()
|
|
};
|
|
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:add_session_at_path",
|
|
&directory_path,
|
|
log::Level::Info,
|
|
);
|
|
|
|
// Run command after session has been added
|
|
if path.is_file() {
|
|
if let Some(path_str) = path.to_str() {
|
|
execute_file(primary_window_id, path_str, ctx);
|
|
}
|
|
}
|
|
} else {
|
|
let open_path = OpenPath {
|
|
path: directory_path,
|
|
};
|
|
ctx.dispatch_global_action("root_view:open_new_from_path", &open_path);
|
|
|
|
// Run command after window has been added
|
|
if path.is_file() {
|
|
let active_window_id = ctx.windows().active_window();
|
|
if let Some(primary_window_id) = get_primary_window(active_window_id, ctx) {
|
|
if let Some(path_str) = path.to_str() {
|
|
execute_file(primary_window_id, path_str, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
send_telemetry_from_app_ctx!(TelemetryEvent::OpenNewSessionFromFilePath, ctx);
|
|
}
|
|
}
|
|
|
|
fn execute_file(window_id: WindowId, path_str: &str, ctx: &mut AppContext) {
|
|
active_terminal_in_window(window_id, ctx, |term, t_ctx| {
|
|
let path_str = term.shell_family(t_ctx).shell_escape(path_str);
|
|
term.input().update(t_ctx, |input, i_ctx| {
|
|
input.set_pending_command(&path_str, i_ctx);
|
|
})
|
|
});
|
|
|
|
send_telemetry_from_app_ctx!(TelemetryEvent::CommandFileRun, ctx);
|
|
}
|
|
|
|
fn open_window_with_action(active_window_id: Option<WindowId>, action: &str, ctx: &mut AppContext) {
|
|
if let Some(primary_window_id) = active_window_id {
|
|
// Dispatch action to primary window
|
|
if let Some(root_view_id) = ctx.root_view_id(primary_window_id) {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
action,
|
|
&(),
|
|
log::Level::Info,
|
|
);
|
|
}
|
|
} else {
|
|
log::warn!("no primary window id to dispatch action to");
|
|
|
|
// Open a new window and dispatch action there
|
|
ctx.dispatch_global_action("root_view:open_new", &());
|
|
// TODO: Note we cannot just dispatch here as it will be a no-op.
|
|
// Need to send a callback once window is fully open.
|
|
}
|
|
}
|
|
|
|
fn find_workspace_for_terminal_view(
|
|
terminal_view_id: EntityId,
|
|
ctx: &mut AppContext,
|
|
) -> Option<(WindowId, ViewHandle<Workspace>)> {
|
|
for window_id in ctx.window_ids() {
|
|
let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) else {
|
|
continue;
|
|
};
|
|
for workspace in workspaces {
|
|
let contains_terminal = workspace
|
|
.as_ref(ctx)
|
|
.list_tab_pane_groups(ctx)
|
|
.iter()
|
|
.any(|group| group.terminal_ids.contains(&terminal_view_id));
|
|
if contains_terminal {
|
|
return Some((window_id, workspace));
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn active_terminal_view_id_in_window(window_id: WindowId, ctx: &AppContext) -> Option<EntityId> {
|
|
let workspaces = ctx.views_of_type::<Workspace>(window_id)?;
|
|
let workspace = workspaces.first()?;
|
|
workspace.read(ctx, |workspace, w_ctx| {
|
|
let pane_group = workspace.active_tab_pane_group().as_ref(w_ctx);
|
|
pane_group
|
|
.active_session_view(w_ctx)
|
|
.map(|terminal_view| terminal_view.id())
|
|
.or_else(|| {
|
|
pane_group
|
|
.terminal_views(w_ctx)
|
|
.first()
|
|
.map(|view| view.id())
|
|
})
|
|
})
|
|
}
|
|
|
|
fn find_cloud_mode_terminal_view_id(
|
|
primary_window_id: Option<WindowId>,
|
|
ctx: &AppContext,
|
|
) -> Option<EntityId> {
|
|
let mut window_ids = Vec::new();
|
|
if let Some(primary_window_id) = primary_window_id {
|
|
window_ids.push(primary_window_id);
|
|
}
|
|
window_ids.extend(
|
|
ctx.window_ids()
|
|
.filter(|window_id| Some(*window_id) != primary_window_id),
|
|
);
|
|
|
|
for window_id in window_ids {
|
|
let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) else {
|
|
continue;
|
|
};
|
|
for workspace in workspaces {
|
|
if let Some(terminal_view_id) = workspace.read(ctx, |workspace, w_ctx| {
|
|
find_cloud_mode_terminal_in_workspace(workspace, w_ctx)
|
|
}) {
|
|
return Some(terminal_view_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn find_cloud_mode_terminal_in_workspace(
|
|
workspace: &Workspace,
|
|
ctx: &AppContext,
|
|
) -> Option<EntityId> {
|
|
let mut fallback_ambient_terminal_id = None;
|
|
|
|
for pane_group_handle in workspace.tab_views() {
|
|
let pane_group = pane_group_handle.as_ref(ctx);
|
|
let ambient_terminal_id =
|
|
pane_group
|
|
.terminal_views(ctx)
|
|
.into_iter()
|
|
.find_map(|terminal_view| {
|
|
terminal_view
|
|
.as_ref(ctx)
|
|
.ambient_agent_view_model()
|
|
.as_ref(ctx)
|
|
.is_ambient_agent()
|
|
.then_some(terminal_view.id())
|
|
});
|
|
|
|
let Some(ambient_terminal_id) = ambient_terminal_id else {
|
|
continue;
|
|
};
|
|
|
|
let has_environment_management_pane = pane_group
|
|
.pane_ids()
|
|
.any(|pane_id| pane_id.is_environment_management_pane());
|
|
if has_environment_management_pane {
|
|
return Some(ambient_terminal_id);
|
|
}
|
|
|
|
if fallback_ambient_terminal_id.is_none() {
|
|
fallback_ambient_terminal_id = Some(ambient_terminal_id);
|
|
}
|
|
}
|
|
|
|
fallback_ambient_terminal_id
|
|
}
|
|
/// Helper function to dispatch an action to an existing window
|
|
/// or create new window if none exist.
|
|
fn dispatch_action_in_new_or_existing_window<T: 'static>(
|
|
primary_window_id: Option<WindowId>,
|
|
existing_window_action: &str,
|
|
new_window_action: &str,
|
|
args: &T,
|
|
ctx: &mut AppContext,
|
|
) {
|
|
let primary_window_and_view = primary_window_id.and_then(|window_id| {
|
|
ctx.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id))
|
|
});
|
|
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
existing_window_action,
|
|
args,
|
|
log::Level::Info,
|
|
);
|
|
} else {
|
|
ctx.dispatch_global_action(new_window_action, args);
|
|
}
|
|
}
|
|
|
|
/// Validates an incoming custom URI for security and returns the host.
|
|
fn validate_custom_uri(url: &Url) -> Result<UriHost> {
|
|
// For now the only scheme we support is `[scheme_name]://[host_str]/...
|
|
// Ignore all other urls that don't match this scheme for security purposes.
|
|
if url.scheme() != ChannelState::url_scheme() {
|
|
return Err(anyhow!(
|
|
"Received url with unexpected scheme: {} ",
|
|
url.scheme()
|
|
));
|
|
}
|
|
|
|
let host_str = url
|
|
.host_str()
|
|
.ok_or_else(|| anyhow!("Received url with no host str"))?;
|
|
|
|
let host = UriHost::from_str(host_str)?;
|
|
|
|
// Check if this host is allowed to have arbitrary paths.
|
|
let host_allows_arbitrary_path = match host {
|
|
UriHost::Action
|
|
| UriHost::Launch
|
|
| UriHost::SharedSession
|
|
| UriHost::Conversation
|
|
| UriHost::Drive
|
|
| UriHost::Team
|
|
| UriHost::Settings
|
|
| UriHost::Mcp
|
|
| UriHost::Codex
|
|
| UriHost::Linear => true,
|
|
// Auth and Home only allow the desktop redirect path
|
|
UriHost::Auth | UriHost::Home => false,
|
|
};
|
|
|
|
ensure!(
|
|
host_allows_arbitrary_path || url.path() == DESKTOP_REDIRECT_URI_PATH,
|
|
"Received url with unexpected path: {} ",
|
|
url.path()
|
|
);
|
|
|
|
Ok(host)
|
|
}
|
|
|
|
/// Formats the non-sensitive components of an incoming URL for logging on
|
|
/// release channels.
|
|
///
|
|
/// The returned string contains only the URL's scheme, host, and path — never
|
|
/// its query string, fragment, or userinfo component. URLs that reach
|
|
/// [`handle_incoming_uri`] can carry secrets in their query (for example, the
|
|
/// Firebase refresh token in `warp://auth/desktop_redirect?refresh_token=...`),
|
|
/// so this helper exists to give [`safe_info!`] a redacted representation that
|
|
/// still preserves enough signal for triage.
|
|
///
|
|
/// `url.host_str()` can return `None` for schemes that don't require a host
|
|
/// (e.g. some `file://` URLs on certain platforms); the literal `-` is used
|
|
/// as a placeholder in that case so the formatter never panics.
|
|
fn safe_url_log_fields(url: &Url) -> String {
|
|
format!(
|
|
"scheme={} host={} path={}",
|
|
url.scheme(),
|
|
url.host_str().unwrap_or("-"),
|
|
url.path(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "uri_test.rs"]
|
|
mod tests;
|