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
+24 -2
View File
@@ -1,6 +1,8 @@
use url::Url;
const DEFAULT_TITLE: &str = "Galaxy";
use super::web_intent_parser::WebIntent;
const DEFAULT_TITLE: &str = "Warp";
const BASE_APP_PATH: &str = "/app";
pub fn update_browser_url(url: Option<Url>, force_redirect: bool) {
@@ -9,7 +11,7 @@ pub fn update_browser_url(url: Option<Url>, force_redirect: bool) {
new_url = get_base_app_url()
}
if let Some(unwrapped_url) = new_url {
if let Some(unwrapped_url) = new_url.and_then(safe_browser_navigation_url) {
let window = gloo::utils::window();
if force_redirect {
let _ = window.location().set_href(unwrapped_url.as_str());
@@ -36,6 +38,16 @@ pub fn update_browser_url(url: Option<Url>, force_redirect: bool) {
}
}
fn safe_browser_navigation_url(url: Url) -> Option<Url> {
match url.scheme() {
"http" | "https" => Some(url),
_ => {
log::warn!("Skipping browser URL update for invalid or unsafe URL");
None
}
}
}
pub fn parse_current_url() -> Option<Url> {
let loc = gloo::utils::document().location();
let unwrapped_loc = loc.as_ref()?;
@@ -54,6 +66,9 @@ pub fn parse_current_url() -> Option<Url> {
fn get_base_app_url() -> Option<Url> {
if let Some(current_url) = parse_current_url() {
if should_preserve_current_url_on_base_fallback(&current_url) {
return Some(current_url);
}
let mut new_url = current_url.clone();
new_url.set_path(BASE_APP_PATH);
new_url.set_query(None);
@@ -62,3 +77,10 @@ fn get_base_app_url() -> Option<Url> {
log::error!("Failed to get the base url");
None
}
fn should_preserve_current_url_on_base_fallback(url: &Url) -> bool {
matches!(
WebIntent::try_from_url(url),
Ok(WebIntent::ConversationView(_) | WebIntent::SessionView(_))
)
}
+7 -9
View File
@@ -1,16 +1,14 @@
use std::{collections::HashMap, fmt::Display};
use std::collections::HashMap;
use std::fmt::Display;
use crate::{
send_telemetry_from_app_ctx, server::telemetry::TelemetryEvent, terminal::shell::ShellType,
};
use galaxy_util::path::{is_posix_portable_pathname, ShellFamily};
use galaxyui::AppContext;
use anyhow::{anyhow, Result};
use regex::Regex;
use url::Url;
use crate::root_view::SubshellCommandArg;
use anyhow::{anyhow, Result};
use crate::send_telemetry_from_app_ctx;
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::shell::ShellType;
/// String of hex digits meant to represent a Docker container ID.
#[derive(Debug)]
@@ -117,5 +115,5 @@ pub fn open_docker_container(url: &Url, ctx: &mut AppContext) -> Result<()> {
}
#[cfg(test)]
#[path = "docker_test.rs"]
#[path = "docker_tests.rs"]
mod tests;
@@ -1,13 +1,10 @@
use galaxyui::App;
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
server::{
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
},
};
use super::*;
use crate::auth::auth_manager::AuthManager;
use crate::auth::AuthStateProvider;
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
#[test]
// Tests behavior based on which query parameters are required.
+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;
+2 -1
View File
@@ -1,7 +1,8 @@
use url::Url;
use crate::cloud_object::extract_server_id_and_object_type_from_warp_drive_link;
use crate::drive::OpenGalaxyDriveObjectArgs;
use crate::ChannelState;
use url::Url;
#[derive(PartialEq, Debug)]
pub enum WarpWebLink {
+941
View File
@@ -0,0 +1,941 @@
use self::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink};
use super::*;
use crate::launch_configs::launch_config::make_mock_single_window_launch_config;
use crate::linear::{LinearAction, LinearIssueWork};
use crate::ChannelState;
#[test]
fn test_find_matching_config() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..5 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
let with_extension = "config1.yaml";
assert_eq!(
find_matching_config(with_extension, &configs),
Some(&configs[1])
);
let no_extension = "config4";
assert_eq!(
find_matching_config(no_extension, &configs),
Some(&configs[4])
);
let caps_insensitive = "ConFig3";
assert_eq!(
find_matching_config(caps_insensitive, &configs),
Some(&configs[3])
);
let missing_config = "missing";
assert_eq!(find_matching_config(missing_config, &configs), None);
}
#[test]
fn test_find_matching_config_with_spaces() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..3 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
let with_space = "config 3.yaml";
add_mock_config_with_name(with_space, &mut configs);
assert_eq!(
find_matching_config(with_space, &configs),
Some(&configs[3])
);
let more_space = " a ";
add_mock_config_with_name(more_space, &mut configs);
assert_eq!(
find_matching_config(more_space, &configs),
Some(&configs[4])
);
}
#[test]
fn test_find_matching_configs_special_chars() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..3 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
// test special characters
let special_ascii = "yes! this_works,too-even[braces}and(parens'.";
add_mock_config_with_name(special_ascii, &mut configs);
assert_eq!(
find_matching_config(special_ascii, &configs),
Some(&configs[3])
);
// test emojis
let bread = "🍞";
add_mock_config_with_name(bread, &mut configs);
assert_eq!(find_matching_config(bread, &configs), Some(&configs[4]));
}
fn add_mock_config_with_name(name: &str, configs: &mut Vec<LaunchConfig>) {
let mut new_config = make_mock_single_window_launch_config();
new_config.name = name.to_string();
new_config.windows[0].tabs[0].title = Some(String::from("First tab from config ") + name);
configs.push(new_config);
}
#[test]
fn test_find_matching_tab_config() {
let configs = vec![
make_mock_tab_config("my tab", Some("/tab_configs/my_tab.toml")),
make_mock_tab_config("Deploy", Some("/tab_configs/Deploy.yaml")),
make_mock_tab_config("dotted", Some("/tab_configs/foo.bar.toml")),
make_mock_tab_config("orphan", None),
];
// Stem match without extension.
assert_eq!(
find_matching_tab_config("my_tab", configs.clone()).map(|c| c.name),
Some(String::from("my tab")),
);
// Stem match with extension.
assert_eq!(
find_matching_tab_config("my_tab.toml", configs.clone()).map(|c| c.name),
Some(String::from("my tab")),
);
// Case-insensitive match.
assert_eq!(
find_matching_tab_config("deploy", configs.clone()).map(|c| c.name),
Some(String::from("Deploy")),
);
// Dotted stem resolves both with and without `.toml`.
assert_eq!(
find_matching_tab_config("foo.bar", configs.clone()).map(|c| c.name),
Some(String::from("dotted")),
);
assert_eq!(
find_matching_tab_config("foo.bar.toml", configs.clone()).map(|c| c.name),
Some(String::from("dotted")),
);
// Miss returns None.
assert!(find_matching_tab_config("unknown", configs.clone()).is_none());
// Configs without a `source_path` never match.
assert!(find_matching_tab_config("orphan", configs).is_none());
}
fn make_mock_tab_config(name: &str, source_path: Option<&str>) -> TabConfig {
TabConfig {
name: name.to_string(),
title: None,
color: None,
panes: vec![],
params: HashMap::new(),
source_path: source_path.map(PathBuf::from),
}
}
#[test]
fn test_get_launch_config_path() {
assert_eq!(
get_launch_config_path("/path/to/a/config"),
Some(String::from("path/to/a/config")),
);
assert_eq!(
get_launch_config_path("/hello%20world.yaml"),
Some(String::from("hello world.yaml")),
);
assert_eq!(
get_launch_config_path("/%3Bhello%20%23world!"),
Some(String::from(";hello #world!")),
);
assert_eq!(
get_launch_config_path("/yes%21%20this_works%2Ctoo-even%5Bbraces%7Dand%28parens%27."),
Some(String::from("yes! this_works,too-even[braces}and(parens'."))
);
assert_eq!(
get_launch_config_path("/%F0%9F%8D%9E"),
Some(String::from("🍞"))
);
assert_eq!(
get_launch_config_path("/..filename_.with_dots.."),
Some(String::from("..filename_.with_dots.."))
);
}
#[test]
fn test_get_launch_config_path_invalid() {
assert_eq!(get_launch_config_path(""), None);
assert_eq!(get_launch_config_path("/"), None);
assert_eq!(get_launch_config_path("%2F"), None);
assert_eq!(get_launch_config_path("/../outside"), None);
assert_eq!(get_launch_config_path("/..%2Foutside"), None);
assert_eq!(get_launch_config_path("/A/.."), None);
assert_eq!(get_launch_config_path("/A/../B"), None);
assert_eq!(get_launch_config_path("//absolute"), None);
assert_eq!(get_launch_config_path("/%2Fabsolute sneaky"), None);
assert_eq!(get_launch_config_path("//../very_bad/.."), None);
}
#[test]
fn test_remove_extension() {
assert_eq!(remove_extension(""), None);
assert_eq!(remove_extension(".yaml"), Some(""));
assert_eq!(remove_extension(" .yaml"), Some(" "));
assert_eq!(remove_extension("config.yaml"), Some("config"));
assert_eq!(remove_extension("..yaml"), Some("."));
assert_eq!(remove_extension("config"), None);
assert_eq!(remove_extension("🍞.yaml"), Some("🍞"));
}
#[test]
fn test_warp_web_link_notebook() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/drive/notebook/Performance-Analysis-LkDlnAe34vfYD2JXsAkssc?focused_folder_id=test_uid00000000000123&invitee_email=test@example.com",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
object_type: ObjectType::Notebook,
server_id: ServerId::from_string_lossy("LkDlnAe34vfYD2JXsAkssc"),
settings: OpenWarpDriveObjectSettings {
focused_folder_id: Some(ServerId::from(123)),
invitee_email: Some(String::from("test@example.com")),
},
})))
);
}
#[test]
fn test_warp_web_link_session() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/session/317d0686-7a0b-4b67-806b-aaa3e9df501b?
pwd=6f727249-af9f-4025-a240-59df40a4c64b",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::Session)
);
}
#[test]
fn test_warp_web_link_workflow() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/drive/workflow/Remove-all-stopped-docker-container-image-and-volumes-ZCJSkai2gpwTqpBFs5HOfZ",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
object_type: ObjectType::Workflow,
server_id: ServerId::from_string_lossy("ZCJSkai2gpwTqpBFs5HOfZ"),
settings: OpenWarpDriveObjectSettings::default(),
})))
);
}
#[test]
fn test_warp_web_link_failure() {
assert_eq!(
get_item_data_from_warp_link(&Url::parse("https://google.com").unwrap()),
None
);
}
#[test]
fn test_app_web_link_rewrites_to_new_cloud_agent_conversation() {
let url = Url::parse(&format!("{}/app", ChannelState::server_root_url())).unwrap();
let intent = web_intent_parser::maybe_rewrite_web_url_to_intent(&url).unwrap();
assert_eq!(
intent.as_str(),
format!(
"{}://action/new_cloud_agent_conversation?source=web_home",
ChannelState::url_scheme()
)
);
}
#[test]
fn test_action_create_environment_parse() {
let url = Url::parse(&format!(
"{}://action/create_environment?repo=foo&repo=bar",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::CreateEnvironment { repos } => {
assert_eq!(repos, vec!["foo".to_owned(), "bar".to_owned()]);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_focus_cloud_mode_parse() {
let url = Url::parse(&format!(
"{}://action/focus_cloud_mode",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::FocusCloudMode));
}
#[test]
fn test_action_create_environment_parse_no_repos() {
let url = Url::parse(&format!(
"{}://action/create_environment",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::CreateEnvironment { repos } => {
assert!(repos.is_empty());
}
_ => panic!("unexpected action: {action:?}"),
}
}
fn open_file_editor_test_path(file_name: &str) -> (String, PathBuf) {
#[cfg(windows)]
let path = format!("C:/tmp/{file_name}");
#[cfg(not(windows))]
let path = format!("/tmp/{file_name}");
(path.clone(), PathBuf::from(path))
}
#[test]
fn test_action_open_file_editor_parse_with_path_only() {
let (path_param, expected_path) = open_file_editor_test_path("test.rs");
let url = Url::parse(&format!(
"{}://action/open_file_editor?path={path_param}",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::OpenFileEditor { path, line_col } => {
assert_eq!(path, expected_path);
assert_eq!(line_col, None);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_open_file_editor_parse_with_line_only() {
let (path_param, expected_path) = open_file_editor_test_path("test.rs");
let url = Url::parse(&format!(
"{}://action/open_file_editor?path={path_param}&line=120",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::OpenFileEditor { path, line_col } => {
assert_eq!(path, expected_path);
assert_eq!(
line_col,
Some(LineAndColumnArg {
line_num: 120,
column_num: None,
})
);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_open_file_editor_parse_with_line_and_column() {
let (path_param, expected_path) = open_file_editor_test_path("test.rs");
let url = Url::parse(&format!(
"{}://action/open_file_editor?path={path_param}&line=120&column=8",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::OpenFileEditor { path, line_col } => {
assert_eq!(path, expected_path);
assert_eq!(
line_col,
Some(LineAndColumnArg {
line_num: 120,
column_num: Some(8),
})
);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_open_file_editor_parse_decodes_percent_encoded_path() {
let (path_param, _) = open_file_editor_test_path("hello%20world.rs");
let (_, expected_path) = open_file_editor_test_path("hello world.rs");
let url = Url::parse(&format!(
"{}://action/open_file_editor?path={path_param}&line=1",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::OpenFileEditor { path, line_col } => {
assert_eq!(path, expected_path);
assert_eq!(
line_col,
Some(LineAndColumnArg {
line_num: 1,
column_num: None,
})
);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_open_file_editor_parse_expands_home_dir() {
let url = Url::parse(&format!(
"{}://action/open_file_editor?path=~/tmp/test.rs&line=1",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::OpenFileEditor { path, line_col } => {
assert_eq!(
path,
PathBuf::from(shellexpand::tilde("~/tmp/test.rs").into_owned())
);
assert_eq!(
line_col,
Some(LineAndColumnArg {
line_num: 1,
column_num: None,
})
);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_open_file_editor_parse_requires_path() {
let url = Url::parse(&format!(
"{}://action/open_file_editor?line=1",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&url).is_err());
}
#[test]
fn test_action_open_file_editor_parse_rejects_relative_path() {
let url = Url::parse(&format!(
"{}://action/open_file_editor?path=src/main.rs&line=1",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&url).is_err());
}
#[test]
fn test_action_open_file_editor_parse_rejects_column_without_line() {
let url = Url::parse(&format!(
"{}://action/open_file_editor?path=/tmp/test.rs&column=8",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&url).is_err());
}
#[test]
fn test_action_open_file_editor_parse_rejects_invalid_line_or_column() {
let invalid_line = Url::parse(&format!(
"{}://action/open_file_editor?path=/tmp/test.rs&line=abc",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&invalid_line).is_err());
let zero_line = Url::parse(&format!(
"{}://action/open_file_editor?path=/tmp/test.rs&line=0",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&zero_line).is_err());
let invalid_column = Url::parse(&format!(
"{}://action/open_file_editor?path=/tmp/test.rs&line=1&column=0",
ChannelState::url_scheme()
))
.unwrap();
assert!(Action::parse(&invalid_column).is_err());
}
#[test]
fn test_action_cloud_agent_setup_parse() {
let url = Url::parse(&format!(
"{}://action/cloud_agent_setup",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::CloudAgentSetup));
}
#[test]
fn test_action_auto_handoff_to_cloud_parse_default_trigger() {
let url = Url::parse(&format!(
"{}://action/auto_handoff_to_cloud",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(
action,
Action::AutoHandoffToCloud {
trigger: AutoCloudHandoffTrigger::Uri,
}
));
}
#[test]
fn test_action_auto_handoff_to_cloud_parse_alias_path() {
let url = Url::parse(&format!(
"{}://action/auto-handoff-to-cloud",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(
action,
Action::AutoHandoffToCloud {
trigger: AutoCloudHandoffTrigger::Uri,
}
));
}
#[test]
fn test_action_auto_handoff_to_cloud_parse_sleep_trigger() {
let url = Url::parse(&format!(
"{}://action/auto_handoff_to_cloud?trigger=sleep",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(
action,
Action::AutoHandoffToCloud {
trigger: AutoCloudHandoffTrigger::MacOsSleep,
}
));
}
#[test]
fn test_action_new_cloud_agent_conversation_parse() {
let url = Url::parse(&format!(
"{}://action/new_cloud_agent_conversation",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::NewCloudAgentConversation));
}
#[test]
fn test_action_new_agent_conversation_parse() {
let url = Url::parse(&format!(
"{}://action/new_agent_conversation",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::NewAgentConversation));
}
#[test]
fn test_validate_custom_uri_linear() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=hello",
ChannelState::url_scheme()
))
.unwrap();
let host = validate_custom_uri(&url).unwrap();
assert!(matches!(host, UriHost::Linear));
}
#[test]
fn test_linear_action_parse_work() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=hello",
ChannelState::url_scheme()
))
.unwrap();
let action = LinearAction::parse(&url).unwrap();
assert_eq!(action, LinearAction::WorkOnIssue);
}
#[test]
fn test_linear_action_parse_unknown_path() {
let url = Url::parse(&format!("{}://linear/unknown", ChannelState::url_scheme())).unwrap();
assert!(LinearAction::parse(&url).is_err());
}
#[test]
fn test_linear_issue_work_with_prompt() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=fix+the+bug",
ChannelState::url_scheme()
))
.unwrap();
let args = LinearIssueWork::from_url(&url);
assert_eq!(args.prompt.as_deref(), Some("fix the bug"));
}
#[test]
fn test_linear_issue_work_without_prompt() {
let url = Url::parse(&format!("{}://linear/work", ChannelState::url_scheme())).unwrap();
let args = LinearIssueWork::from_url(&url);
assert!(args.prompt.is_none());
}
#[test]
fn test_linear_issue_work_empty_prompt() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=",
ChannelState::url_scheme()
))
.unwrap();
let args = LinearIssueWork::from_url(&url);
assert!(args.prompt.is_none());
}
// -- handle_incoming_uri validation errors -----------------------------------
/// `validate_custom_uri` returns `anyhow::Error`s whose messages feed the
/// non-dogfood `log::warn!("Custom URI is invalid: {e:?}")` fallback in
/// `handle_incoming_uri`. Those messages must never embed the full URL, its
/// query string, or its fragment — otherwise the fallback warn line becomes
/// a second secret leak.
#[test]
fn validate_custom_uri_errors_do_not_leak_query_string() {
// Unexpected scheme.
let url = Url::parse("https://auth/desktop_redirect?refresh_token=LEAKED").unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
// Unexpected host.
let url = Url::parse(&format!(
"{}://unknown_host/desktop_redirect?refresh_token=LEAKED",
ChannelState::url_scheme()
))
.unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
// Unexpected path for a host that doesn't allow arbitrary paths.
let url = Url::parse(&format!(
"{}://auth/not_the_redirect?refresh_token=LEAKED",
ChannelState::url_scheme()
))
.unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
}
#[test]
fn test_parse_tab_path_expands_tilde() {
let url = Url::parse("warp://action/new_tab?path=~/Projects").unwrap();
let home = dirs::home_dir().expect("HOME must be set for this test");
assert_eq!(parse_tab_path(&url), Some(home.join("Projects")));
}
#[test]
fn test_parse_tab_path_expands_url_encoded_tilde() {
// `%7E` and `%2F` are URL-encoded `~` and `/`.
let url = Url::parse("warp://action/new_tab?path=%7E%2FProjects").unwrap();
let home = dirs::home_dir().expect("HOME must be set for this test");
assert_eq!(parse_tab_path(&url), Some(home.join("Projects")));
}
#[test]
fn test_parse_tab_path_absolute_path_unchanged() {
let url = Url::parse("warp://action/new_tab?path=/tmp/foo").unwrap();
assert_eq!(parse_tab_path(&url), Some(PathBuf::from("/tmp/foo")));
}
#[test]
fn test_parse_tab_path_relative_path_unchanged() {
let url = Url::parse("warp://action/new_tab?path=relative/dir").unwrap();
assert_eq!(parse_tab_path(&url), Some(PathBuf::from("relative/dir")));
}
#[test]
fn test_parse_tab_path_missing_returns_none() {
let url = Url::parse("warp://action/new_tab").unwrap();
assert_eq!(parse_tab_path(&url), None);
}
#[test]
fn test_parse_tab_path_bare_tilde() {
let url = Url::parse("warp://action/new_tab?path=~").unwrap();
let home = dirs::home_dir().expect("HOME must be set for this test");
assert_eq!(parse_tab_path(&url), Some(home));
}
// -- warp://settings deeplink parsing ----------------------------------------
#[test]
fn test_settings_widget_deeplink_target() {
assert_eq!(
settings_widget_deeplink_target("global_hotkey").map(|(section, _)| section),
Some(SettingsSection::Features),
);
assert_eq!(
settings_widget_deeplink_target("custom_router").map(|(section, _)| section),
Some(SettingsSection::WarpAgent),
);
#[cfg(not(target_family = "wasm"))]
assert_eq!(
settings_widget_deeplink_target("cli_agents").map(|(section, _)| section),
Some(SettingsSection::ThirdPartyCLIAgents),
);
// Unknown / empty slugs are not linkable (allowlist only).
assert!(settings_widget_deeplink_target("not_a_widget").is_none());
assert!(settings_widget_deeplink_target("").is_none());
}
#[test]
fn test_settings_section_for_simple_subpage() {
assert_eq!(
settings_section_for_simple_subpage("appearance"),
Some(SettingsSection::Appearance),
);
assert_eq!(
settings_section_for_simple_subpage("billing_and_usage"),
Some(SettingsSection::BillingAndUsage),
);
assert_eq!(
settings_section_for_simple_subpage("platform"),
Some(SettingsSection::OzCloudAPIKeys),
);
assert_eq!(
settings_section_for_simple_subpage("warp_agent"),
Some(SettingsSection::WarpAgent),
);
assert!(settings_section_for_simple_subpage("not_a_subpage").is_none());
}
// Regression coverage for issue #9005: shell scripts opened via `file://` should run,
// not open in the editor. Exercised through the pure routing helper to avoid standing
// up a full `AppContext`.
#[test]
#[cfg(unix)]
fn test_open_file_executable_sh_routes_to_execute() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("run.sh");
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
let action = classify_open_file_action(&p);
assert_eq!(action, OpenFileAction::ExecuteInSession);
}
#[test]
#[cfg(unix)]
fn test_open_file_non_executable_sh_routes_to_editor() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("view.sh");
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
}
#[test]
#[cfg(unix)]
fn test_open_file_executable_bash_zsh_fish_route_to_execute() {
let dir = tempfile::tempdir().unwrap();
for name in ["run.bash", "run.zsh", "run.fish", "run.command"] {
let p = dir.path().join(name);
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
classify_open_file_action(&p),
OpenFileAction::ExecuteInSession,
"{name} should route to ExecuteInSession",
);
}
}
#[test]
fn test_open_file_markdown_unchanged() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("README.md");
std::fs::write(&p, b"# hi\n").unwrap();
assert_eq!(classify_open_file_action(&p), OpenFileAction::Notebook);
}
#[test]
#[cfg(feature = "local_fs")]
fn test_open_file_rust_source_still_opens_in_editor() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("main.rs");
std::fs::write(&p, b"fn main() {}\n").unwrap();
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
}
#[test]
#[cfg(unix)]
fn test_open_file_editor_executable_sh_opens_in_editor() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("run.sh");
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(can_open_file_editor_path(&p));
}
#[test]
#[cfg(feature = "local_fs")]
fn test_open_file_editor_rust_source_opens_in_editor() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("main.rs");
std::fs::write(&p, b"fn main() {}\n").unwrap();
assert!(can_open_file_editor_path(&p));
}
#[test]
#[cfg(feature = "local_fs")]
fn test_open_file_editor_binary_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("image.png");
std::fs::write(&p, b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR").unwrap();
assert!(!can_open_file_editor_path(&p));
}
#[test]
fn test_open_file_directory_routes_to_session() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
classify_open_file_action(dir.path()),
OpenFileAction::ExecuteInSession
);
}
#[test]
#[cfg(unix)]
fn test_open_file_non_runnable_shebang_routes_to_editor() {
// Extensionless `#!/bin/sh` file without the user-execute bit. Without the
// shebang fall-through this would hit `ExecuteInSession` and the shell would
// refuse to run it; the editor is the right place to view it.
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("noext");
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
}
#[test]
fn test_session_uri_host_parsing() {
let result = UriHost::from_str("session");
assert!(matches!(result, Ok(UriHost::Session)));
}
#[test]
fn test_session_uri_validation() {
let url = Url::parse(&format!(
"{}://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
ChannelState::url_scheme()
))
.unwrap();
let host = validate_custom_uri(&url).unwrap();
assert!(matches!(host, UriHost::Session));
}
#[test]
fn test_session_uri_empty_path_does_not_panic() {
let url = Url::parse(&format!("{}://session/", ChannelState::url_scheme())).unwrap();
let host = validate_custom_uri(&url).unwrap();
assert!(matches!(host, UriHost::Session));
}
#[test]
fn test_session_uri_invalid_hex_does_not_panic() {
let url = Url::parse(&format!(
"{}://session/ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
ChannelState::url_scheme()
))
.unwrap();
let host = validate_custom_uri(&url).unwrap();
assert!(matches!(host, UriHost::Session));
}
#[test]
fn test_session_uri_case_insensitive_hex() {
let upper = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4";
let lower = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
let upper_bytes = super::decode_uuid_hex(upper).expect("upper hex should decode");
let lower_bytes = super::decode_uuid_hex(lower).expect("lower hex should decode");
assert_eq!(upper_bytes, lower_bytes);
assert_eq!(upper_bytes.len(), 16);
}
#[test]
fn test_decode_uuid_hex_rejects_wrong_length() {
assert!(super::decode_uuid_hex("ABCD").is_none());
assert!(super::decode_uuid_hex("").is_none());
assert!(super::decode_uuid_hex("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4FF").is_none());
}
#[test]
fn test_decode_uuid_hex_rejects_invalid_chars() {
assert!(super::decode_uuid_hex("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ").is_none());
}
+10 -6
View File
@@ -1,13 +1,13 @@
#[cfg(target_family = "wasm")]
use crate::uri::browser_url_handler::parse_current_url;
use crate::ChannelState;
use anyhow::{anyhow, Result};
use url::Url;
use uuid::Uuid;
#[cfg(target_family = "wasm")]
use galaxy_core::context_flag::ContextFlag;
#[cfg(target_family = "wasm")]
use crate::uri::browser_url_handler::parse_current_url;
use crate::ChannelState;
#[derive(Debug)]
/// Represents an intent parsed from a web url
pub enum WebIntent {
@@ -16,6 +16,7 @@ pub enum WebIntent {
DriveObject(Url),
SettingsView(Url),
Home(Url),
CloudAgentHome(Url),
Action(Url),
}
@@ -43,8 +44,8 @@ impl WebIntent {
} else {
match segments[0] {
"app" => {
return Ok(WebIntent::Home(Url::parse(&format!(
"{url_scheme}://home"
return Ok(WebIntent::CloudAgentHome(Url::parse(&format!(
"{url_scheme}://action/new_cloud_agent_conversation?source=web_home"
))?));
}
// For sessions, we expect the URL to be in the format: {scheme}/session/{session_id}
@@ -154,6 +155,7 @@ impl WebIntent {
WebIntent::DriveObject(url) => url,
WebIntent::SettingsView(url) => url,
WebIntent::Home(url) => url,
WebIntent::CloudAgentHome(url) => url,
WebIntent::Action(url) => url,
}
}
@@ -174,6 +176,7 @@ pub fn open_url_on_desktop(url: &Url) {
Ok(WebIntent::ConversationView(intent))
| Ok(WebIntent::DriveObject(intent))
| Ok(WebIntent::SessionView(intent))
| Ok(WebIntent::CloudAgentHome(intent))
| Ok(WebIntent::Action(intent)) => {
crate::platform::wasm::emit_event(crate::platform::wasm::GalaxyEvent::OpenOnNative {
url: intent.into(),
@@ -193,6 +196,7 @@ fn set_context_flags_from_url(url: Url) {
Ok(WebIntent::DriveObject(_)) => ContextFlag::set_warp_drive_link_only(),
Ok(WebIntent::SettingsView(_)) => ContextFlag::set_settings_link_only(),
Ok(WebIntent::Home(_)) => ContextFlag::set_warp_home_link_only(),
Ok(WebIntent::CloudAgentHome(_)) => {}
Ok(WebIntent::Action(_)) => {} // No special context flag for actions
_ => {}
}