first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+491 -130
View File
@@ -5,40 +5,53 @@ pub mod web_intent_parser;
#[cfg(target_family = "wasm")]
pub mod browser_url_handler;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{anyhow, ensure, Result};
use itertools::Itertools;
use session_sharing_protocol::common::SessionId;
use url::Url;
use warp_util::path::LineAndColumnArg;
use warpui::notification::UserNotification;
use warpui::platform::TerminationMode;
use warpui::{AppContext, EntityId, SingletonEntity as _, TypedActionView, ViewHandle, WindowId};
use self::docker::open_docker_container;
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::api::ServerConversationToken;
use crate::drive::OpenGalaxyDriveObjectSettings;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::cloud_object::ObjectType;
use crate::drive::{OpenWarpDriveObjectArgs, OpenWarpDriveObjectSettings};
use crate::features::FeatureFlag;
use crate::launch_configs::launch_config::LaunchConfig;
use crate::linear::{LinearAction, LinearIssueWork};
use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg};
use crate::root_view::{
open_new_window_get_handles, open_new_with_workspace_source, NewWorkspaceSource,
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::OpenGalaxyDriveObjectArgs, 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::SettingsSection;
use crate::user_config::load_launch_configs;
use crate::settings_view::{
settings_widget_deeplink_target, OpenTeamsSettingsModalArgs, SettingsSection,
};
use crate::tab_configs::TabConfig;
use crate::user_config::{load_launch_configs, load_tab_configs, tab_configs_dir};
use crate::util::openable_file_type::{
is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, starts_with_shebang,
};
use crate::view_components::DismissibleToast;
use crate::workspace::auto_handoff::trigger_auto_handoff_to_cloud;
use crate::workspace::util::PaneViewLocator;
use crate::workspace::{
active_terminal_in_window, AutoCloudHandoffTrigger, ToastStack, Workspace, WorkspaceAction,
WorkspaceRegistry,
};
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 galaxyui::notification::UserNotification;
use galaxyui::{platform::TerminationMode, SingletonEntity as _, TypedActionView};
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 galaxyui::{AppContext, EntityId, ViewHandle, WindowId};
use self::docker::open_docker_container;
const DESKTOP_REDIRECT_URI_PATH: &str = "/desktop_redirect";
@@ -49,6 +62,20 @@ pub struct OpenMCPSettingsArgs {
pub autoinstall: Option<String>,
}
/// Args for the `warp://settings` deeplink family, dispatched to the
/// `root_view:open_settings_in_{existing,new}_window` actions.
pub enum OpenSettingsArgs {
/// `warp://settings` — open a settings tab on the default page.
Default,
/// `warp://settings?q=<query>` — open settings with the search bar pre-filled.
Search { query: String },
/// `warp://settings?widget=<widget_id>` — open settings scrolled to a widget.
Widget {
page: SettingsSection,
widget_id: &'static str,
},
}
/// 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";
@@ -78,6 +105,10 @@ pub enum UriHost {
Codex,
/// Actions triggered from Linear integrations (e.g. work on issue).
Linear,
/// Opens a saved tab config in an existing window or a new one.
TabConfig,
/// Focuses a specific terminal pane by its persistent session UUID.
Session,
}
impl FromStr for UriHost {
@@ -99,6 +130,8 @@ impl FromStr for UriHost {
"mcp" => Ok(Self::Mcp),
"codex" => Ok(Self::Codex),
"linear" => Ok(Self::Linear),
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
"session" => Ok(Self::Session),
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
}
}
@@ -184,6 +217,9 @@ impl UriHost {
log::warn!("couldn't turn launch link '{}' into path", url.path());
}
}
UriHost::TabConfig => {
handle_tab_config_uri(primary_window_id, url, ctx);
}
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}
@@ -256,7 +292,7 @@ impl UriHost {
// 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.
// If this parameter is present, we will open the sharing dialog with the email filled in.
let object_type = url
.path_segments()
.into_iter()
@@ -317,92 +353,133 @@ impl UriHost {
}
UriHost::Settings => {
// We support opening different settings pages through URI:
// - warp://settings - opens a settings tab on the default page
// - warp://settings?q={query} - opens settings with the search bar pre-filled
// - warp://settings?widget={widget_id} - opens settings scrolled to a widget
// - 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.)
// - warp://settings/warp_agent - opens the Warp Agent settings page (inference / API keys)
let query_string: HashMap<_, _> = url.query_pairs().collect();
// A bare `warp://settings` (or a trailing slash) yields an empty path
// segment; treat that as "no sub-page" so the query-param routing below
// handles it.
let settings_sub_page: Option<String> = url
.path_segments()
.into_iter()
.flatten()
.last()
.filter(|s| !s.is_empty())
.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" => {
// Teams feature removed
}
"billing_and_usage" => {
match settings_sub_page.as_deref() {
Some("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,
);
}
Some("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::BillingAndUsage,
&SettingsSection::CloudEnvironments,
ctx,
);
}
"environments" => {
// Notify that GitHub auth completed so views can refresh
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
notifier.notify_auth_completed(ctx);
});
}
Some("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,
);
}
// No special sub-page: route the bare host, the `q` (search) and
// `widget` (scroll-to) query params, and the simple section
// sub-pages (e.g. billing_and_usage, platform, appearance,
// warp_agent) resolved via `settings_section_for_simple_subpage`.
maybe_simple_subpage => {
let simple_section =
maybe_simple_subpage.and_then(settings_section_for_simple_subpage);
// Pull the non-empty `q` search query out of the already
// parsed pairs to pre-fill the settings search bar.
let search_query = query_string
.get("q")
.map(|query| query.to_string())
.filter(|query| !query.is_empty());
let widget_target = query_string
.get("widget")
.and_then(|slug| settings_widget_deeplink_target(slug));
// 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::Appearance,
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 };
if let Some((page, widget_id)) = widget_target {
// `?widget=` scrolls to a specific widget; it takes
// precedence over `?q=` since searching would filter the
// target widget out of view.
let args = OpenSettingsArgs::Widget { page, widget_id };
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",
"root_view:open_settings_in_existing_window",
"root_view:open_settings_in_new_window",
&args,
ctx,
);
}
"platform" => {
} else if let Some(query) = search_query {
let args = OpenSettingsArgs::Search { query };
dispatch_action_in_new_or_existing_window(
primary_window_id,
"root_view:open_settings_in_existing_window",
"root_view:open_settings_in_new_window",
&args,
ctx,
);
} else if let Some(section) = simple_section {
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,
&section,
ctx,
);
}
"appearance" => {
} else if maybe_simple_subpage.is_none() {
// Bare `warp://settings` opens the default settings page.
let args = OpenSettingsArgs::Default;
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,
"root_view:open_settings_in_existing_window",
"root_view:open_settings_in_new_window",
&args,
ctx,
);
}
_ => {
log::warn!("Failed to open settings pane with uri={url}");
} else {
log::warn!("Failed to open settings pane: unrecognized sub-page");
}
}
} else {
log::warn!("Failed to open settings pane with uri={url}");
}
}
UriHost::Home => {
@@ -442,11 +519,59 @@ impl UriHost {
log::warn!("{err}");
}
},
UriHost::Session => {
let uuid_hex = url
.path_segments()
.into_iter()
.flatten()
.last()
.unwrap_or("");
let Some(uuid_bytes) = decode_uuid_hex(uuid_hex) else {
log::warn!(
"session deep link received invalid UUID hex (safe: len={})",
uuid_hex.len()
);
return;
};
let result = WorkspaceRegistry::as_ref(ctx)
.all_workspaces(ctx)
.iter()
.find_map(|(win_id, workspace)| {
workspace.as_ref(ctx).tab_views().find_map(|pane_group| {
let pane_id = pane_group
.as_ref(ctx)
.find_terminal_pane_by_session_uuid(&uuid_bytes)?;
Some((
*win_id,
PaneViewLocator {
pane_group_id: pane_group.id(),
pane_id,
},
))
})
});
if let Some((window_id, locator)) = result {
ctx.windows().show_window_and_focus_app(window_id);
if let Some(root_view_id) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view_id,
"root_view:handle_pane_navigation_event",
&locator,
);
}
} else {
log::warn!("session deep link could not find pane with given UUID");
}
}
}
}
/// When handling this URI action, determine which window(s) should be focused.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn window_behavior_hint(&self) -> WindowBehaviorHint {
use WindowBehaviorHint as W;
match self {
@@ -464,6 +589,9 @@ impl UriHost {
Self::Codex => W::default(),
// Linear deeplink opens a new tab with agent view
Self::Linear => W::default(),
// Handler picks the window itself based on `?new_window=true`.
Self::TabConfig => W::Nothing,
Self::Session => W::Nothing,
}
}
}
@@ -490,7 +618,7 @@ impl Default for WindowBehaviorHint {
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))]
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn resolve(
self,
primary_window_id: Option<WindowId>,
@@ -538,7 +666,7 @@ enum WindowActivationFallbackBehavior {
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))]
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn resolve(self, primary_window_id: WindowId, ctx: &mut AppContext) -> Option<WindowId> {
match self {
WindowActivationFallbackBehavior::Notify { title, description } => {
@@ -649,6 +777,80 @@ fn find_matching_config_name<'a>(
.find(|&config| config.name.to_lowercase() == target_name_lower)
}
/// Handles `warp://tab_config/<name>` deeplinks.
///
/// Resolution rules:
/// - `<name>` is matched case-insensitively against each tab config's file
/// stem, so both `warp://tab_config/my_tab` and
/// `warp://tab_config/my_tab.toml` work.
/// - When `?new_window=true` (or no Warp window is open) the tab config opens
/// in a brand-new window. Otherwise it opens as a new tab in the active
/// window.
fn handle_tab_config_uri(primary_window_id: Option<WindowId>, url: &Url, ctx: &mut AppContext) {
let Some(desired) = get_launch_config_path(url.path()) else {
log::warn!("couldn't turn tab config link '{}' into name", url.path());
return;
};
let (configs, _errors) = load_tab_configs(&tab_configs_dir());
let Some(config) = find_matching_tab_config(desired.as_str(), configs) else {
log::warn!("couldn't find a tab config matching '{}'", desired);
return;
};
let force_new_window = url
.query_pairs()
.any(|(k, v)| k == "new_window" && matches!(v.as_ref(), "1" | "true"));
let target_window_id = if force_new_window {
None
} else {
primary_window_id.filter(|id| WorkspaceRegistry::as_ref(ctx).get(*id, ctx).is_some())
};
let workspace = match target_window_id {
Some(window_id) => WorkspaceRegistry::as_ref(ctx).get(window_id, ctx),
None => {
let new_window_id = open_new_window_get_handles(None, ctx).0;
WorkspaceRegistry::as_ref(ctx).get(new_window_id, ctx)
}
};
let Some(workspace) = workspace else {
log::warn!(
"no workspace available to open tab config '{}'",
config.name
);
return;
};
workspace.update(ctx, |workspace, ctx| {
workspace.open_tab_config(config, ctx);
});
}
/// Case-insensitive match against each tab config's file stem. Tab config
/// `name` fields are not unique across files, so we key off the filename.
///
/// Tries the target as-is first, then with the extension stripped, so both
/// `my_tab` and `my_tab.toml` resolve to `my_tab.toml` and dotted stems like
/// `foo.bar` (from `foo.bar.toml`) still work when written without `.toml`.
fn find_matching_tab_config(target: &str, configs: Vec<TabConfig>) -> Option<TabConfig> {
let raw = target.to_lowercase();
let stripped = remove_extension(target).map(str::to_lowercase);
configs.into_iter().find(|c| {
c.source_path
.as_ref()
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.map(|s| {
let stem = s.to_lowercase();
stem == raw || Some(stem.as_str()) == stripped.as_deref()
})
.unwrap_or(false)
})
}
/// Extract the `path` query parameter, expanding a leading `~` to the
/// user's home directory.
fn parse_tab_path(url: &Url) -> Option<PathBuf> {
@@ -656,17 +858,77 @@ fn parse_tab_path(url: &Url) -> Option<PathBuf> {
Some(PathBuf::from(shellexpand::tilde(&raw).into_owned()))
}
fn parse_positive_usize_query_param(url: &Url, name: &str) -> Result<Option<usize>> {
let Some(raw) = url.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v) else {
return Ok(None);
};
let value = raw.parse::<usize>()?;
ensure!(value > 0, "`{name}` must be greater than 0");
Ok(Some(value))
}
fn parse_open_file_editor_url(url: &Url) -> Result<(PathBuf, Option<LineAndColumnArg>)> {
let raw_path = url
.query_pairs()
.find(|(k, _)| k == "path")
.map(|(_, v)| v)
.ok_or_else(|| anyhow!("missing path for open_file_editor action"))?;
let path = PathBuf::from(shellexpand::tilde(&raw_path).into_owned());
ensure!(
path.is_absolute(),
"`path` must be absolute for open_file_editor action"
);
let line = parse_positive_usize_query_param(url, "line")?;
let column = parse_positive_usize_query_param(url, "column")?;
ensure!(
line.is_some() || column.is_none(),
"`column` requires `line` for open_file_editor action"
);
Ok((
path,
line.map(|line_num| LineAndColumnArg {
line_num,
column_num: column,
}),
))
}
fn parse_auto_handoff_trigger(url: &Url) -> AutoCloudHandoffTrigger {
match url
.query_pairs()
.find(|(k, _)| k == "trigger")
.map(|(_, v)| v)
{
Some(trigger) if matches!(trigger.as_ref(), "sleep" | "macos_sleep" | "macos-sleep") => {
AutoCloudHandoffTrigger::MacOsSleep
}
Some(_) | None => AutoCloudHandoffTrigger::Uri,
}
}
#[derive(Debug)]
enum Action {
NewTab,
NewWindow,
OpenFileEditor {
path: PathBuf,
line_col: Option<LineAndColumnArg>,
},
Docker,
OpenRepo,
CloudAgentSetup,
NewCloudAgentConversation,
NewAgentConversation,
CreateEnvironment { repos: Vec<String> },
CreateEnvironment {
repos: Vec<String>,
},
FocusCloudMode,
AutoHandoffToCloud {
trigger: AutoCloudHandoffTrigger,
},
}
impl Action {
@@ -674,6 +936,10 @@ impl Action {
match url.path() {
"/new_tab" => Ok(Self::NewTab),
"/new_window" => Ok(Self::NewWindow),
"/open_file_editor" => {
let (path, line_col) = parse_open_file_editor_url(url)?;
Ok(Self::OpenFileEditor { path, line_col })
}
"/docker/open_subshell" => Ok(Self::Docker),
"/open-repo" => Ok(Self::OpenRepo),
"/cloud_agent_setup" => Ok(Self::CloudAgentSetup),
@@ -688,6 +954,9 @@ impl Action {
Ok(Self::CreateEnvironment { repos })
}
"/focus_cloud_mode" => Ok(Self::FocusCloudMode),
"/auto_handoff_to_cloud" | "/auto-handoff-to-cloud" => Ok(Self::AutoHandoffToCloud {
trigger: parse_auto_handoff_trigger(url),
}),
_ => Err(anyhow!(
"Received \"action\" intent with unexpected action: {}",
url.path()
@@ -696,7 +965,7 @@ impl Action {
}
fn handle(&self, primary_window_id: Option<WindowId>, url: &Url, ctx: &mut AppContext) {
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let primary_window_id = self.window_behavior_hint().resolve(primary_window_id, ctx);
match self {
Self::NewTab | Self::NewWindow => {
@@ -711,6 +980,15 @@ impl Action {
};
open_file(window_id, path, ctx);
}
Self::OpenFileEditor { path, line_col } => {
#[cfg(feature = "local_fs")]
open_file_editor(primary_window_id, path.clone(), *line_col, ctx);
#[cfg(not(feature = "local_fs"))]
{
let _ = (path, line_col);
log::warn!("open_file_editor action requires local_fs support");
}
}
Action::Docker => {
if let Err(err) = open_docker_container(url, ctx) {
if let Some(window_id) = primary_window_id {
@@ -774,13 +1052,8 @@ impl 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"
);
let Some(window_id) = primary_window_id else {
open_new_with_workspace_source(NewWorkspaceSource::AmbientAgent, ctx);
return;
};
@@ -846,11 +1119,6 @@ impl Action {
}
}
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));
@@ -886,10 +1154,17 @@ impl Action {
ctx,
);
});
// Notify after focusing so Cloud Mode panes can retry in the selected pane.
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
notifier.notify_auth_completed(ctx);
});
return;
}
}
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
notifier.notify_auth_completed(ctx);
});
dispatch_action_in_new_or_existing_window(
primary_window_id,
"root_view:open_settings_page_in_existing_window",
@@ -898,21 +1173,25 @@ impl Action {
ctx,
);
}
Action::AutoHandoffToCloud { trigger } => {
trigger_auto_handoff_to_cloud(*trigger, ctx);
}
}
}
/// When handling this URI action, determine which window(s) should be focused.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn window_behavior_hint(&self) -> WindowBehaviorHint {
use WindowBehaviorHint as W;
match self {
Self::Docker
| Self::OpenFileEditor { .. }
| Self::CreateEnvironment { .. }
| Self::OpenRepo
| Self::CloudAgentSetup
| Self::NewCloudAgentConversation
| Self::NewAgentConversation
| Self::FocusCloudMode => W::default(),
| Self::FocusCloudMode
| Self::AutoHandoffToCloud { .. } => 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(),
@@ -925,13 +1204,8 @@ impl Action {
/// 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)),
safe: ("received url"),
full: ("received url {:?}", &url)
);
@@ -953,7 +1227,7 @@ pub fn handle_incoming_uri(url: &Url, ctx: &mut AppContext) {
match validate_custom_uri(url) {
Ok(host) => {
#[cfg(any(target_os = "linux", windows))]
#[cfg(any(target_os = "linux", target_os = "freebsd", windows))]
let primary_window_id = host.window_behavior_hint().resolve(primary_window_id, ctx);
host.handle(primary_window_id, url, ctx);
}
@@ -996,6 +1270,39 @@ fn get_primary_window(
non_quake_mode_windows.next()
}
/// What `open_file` should do with an incoming `file://` URL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenFileAction {
/// Open in the markdown notebook pane.
Notebook,
/// Open in Warp's code/text editor pane.
Editor,
/// Open a session at the parent directory and queue the file as the pending command,
/// or just open a session at the directory path if `path` is a directory.
ExecuteInSession,
}
/// Pure routing decision for `open_file`. Extracted so it can be unit-tested without
/// standing up a full `AppContext`.
fn classify_open_file_action(path: &Path) -> OpenFileAction {
if is_markdown_file(path) {
OpenFileAction::Notebook
} else if is_runnable_shell_script(path) {
OpenFileAction::ExecuteInSession
} else if path.is_file()
&& (is_file_openable_in_warp(path).is_some() || starts_with_shebang(path))
{
OpenFileAction::Editor
} else {
OpenFileAction::ExecuteInSession
}
}
#[cfg(feature = "local_fs")]
fn can_open_file_editor_path(path: &Path) -> bool {
path.is_file() && is_file_openable_in_warp(path).is_some()
}
/// Handle an incoming `file://` URL.
/// * Markdown files are opened as notebook panes.
/// * For directories, open a new session at the directory path.
@@ -1007,7 +1314,9 @@ fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
.map(|view_id| (window_id, view_id))
});
if is_markdown_file(&path) {
let action = classify_open_file_action(&path);
if action == OpenFileAction::Notebook {
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
ctx.dispatch_action(
primary_window_id,
@@ -1019,15 +1328,13 @@ fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
} 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() {
} else if action == OpenFileAction::Editor {
#[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,
};
use crate::util::file::external_editor::EditorSettings;
use crate::util::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);
@@ -1102,6 +1409,56 @@ fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
}
}
#[cfg(feature = "local_fs")]
fn open_file_editor(
primary_window_id: Option<WindowId>,
path: PathBuf,
line_col: Option<LineAndColumnArg>,
ctx: &mut AppContext,
) {
#[cfg(feature = "local_fs")]
{
if !can_open_file_editor_path(&path) {
log::warn!("open_file_editor action rejected non-openable path: {path:?}");
return;
}
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_id.and_then(|window_id| {
ctx.root_view_id(window_id)
.map(|view_id| (window_id, view_id))
}) {
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::Link {
path: path.clone(),
range_start: line_col,
range_end: None,
};
workspace.open_file_with_target(path, target, line_col, source, 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);
@@ -1220,8 +1577,7 @@ fn find_cloud_mode_terminal_in_workspace(
terminal_view
.as_ref(ctx)
.ambient_agent_view_model()
.as_ref(ctx)
.is_ambient_agent()
.is_some()
.then_some(terminal_view.id())
});
@@ -1270,6 +1626,16 @@ fn dispatch_action_in_new_or_existing_window<T: 'static>(
}
}
fn settings_section_for_simple_subpage(subpage: &str) -> Option<SettingsSection> {
match subpage {
"billing_and_usage" => Some(SettingsSection::BillingAndUsage),
"platform" => Some(SettingsSection::OzCloudAPIKeys),
"appearance" => Some(SettingsSection::Appearance),
"warp_agent" => Some(SettingsSection::WarpAgent),
_ => None,
}
}
/// 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]/...
@@ -1298,7 +1664,9 @@ fn validate_custom_uri(url: &Url) -> Result<UriHost> {
| UriHost::Settings
| UriHost::Mcp
| UriHost::Codex
| UriHost::Linear => true,
| UriHost::Linear
| UriHost::TabConfig
| UriHost::Session => true,
// Auth and Home only allow the desktop redirect path
UriHost::Auth | UriHost::Home => false,
};
@@ -1312,28 +1680,21 @@ fn validate_custom_uri(url: &Url) -> Result<UriHost> {
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(),
)
fn decode_uuid_hex(hex: &str) -> Option<Vec<u8>> {
let hex = hex.as_bytes();
if hex.len() != 32 {
return None;
}
hex.chunks_exact(2)
.map(|pair| {
let high = (pair[0] as char).to_digit(16)?;
let low = (pair[1] as char).to_digit(16)?;
Some(((high << 4) | low) as u8)
})
.collect()
}
#[cfg(test)]
#[path = "uri_test.rs"]
#[path = "uri_tests.rs"]
mod tests;