first pass of merging in warp (doesn't build)
This commit is contained in:
+491
-130
@@ -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,
|
||||
§ion,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user