diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index 700cb8dba..27339faa2 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -10,7 +10,7 @@ use crate::ai::agent::api::ServerConversationToken; use crate::drive::OpenWarpDriveObjectSettings; use crate::launch_configs::launch_config::LaunchConfig; use crate::linear::{LinearAction, LinearIssueWork}; -use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg}; +use crate::root_view::{open_new_window_get_handles, workspace_for_window, 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}; @@ -21,7 +21,7 @@ use crate::{features::FeatureFlag, workspace::active_terminal_in_window}; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::settings_view::{OpenTeamsSettingsModalArgs, SettingsSection}; -use crate::user_config::load_launch_configs; +use crate::user_config::{load_launch_configs, load_tab_configs, tab_configs_dir}; use crate::{ quake_mode_window_id, quake_mode_window_is_open, safe_info, send_telemetry_from_app_ctx, ChannelState, OpenPath, @@ -78,6 +78,8 @@ 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, } impl FromStr for UriHost { @@ -99,6 +101,7 @@ impl FromStr for UriHost { "mcp" => Ok(Self::Mcp), "codex" => Ok(Self::Codex), "linear" => Ok(Self::Linear), + "tabconfig" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig), _ => Err(anyhow!("Received url with unexpected host: {}", s)), } } @@ -184,6 +187,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} @@ -472,6 +478,8 @@ 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, } } } @@ -657,6 +665,84 @@ fn find_matching_config_name<'a>( .find(|&config| config.name.to_lowercase() == target_name_lower) } +/// Handles `warp://tabconfig/` deeplinks. +/// +/// Resolution rules: +/// - `` is matched case-insensitively against each tab config's `name` +/// field, then against the file stem as a fallback. +/// - 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, 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 config = config.clone(); + + 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| workspace_for_window(*id, ctx).is_some()) + }; + + let workspace = match target_window_id { + Some(window_id) => workspace_for_window(window_id, ctx), + None => { + let new_window_id = open_new_window_get_handles(None, ctx).0; + workspace_for_window(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 lookup that matches the tab config's `name` first, then +/// the file stem (so both `warp://tabconfig/My%20Tab` and +/// `warp://tabconfig/my_tab.toml` work). +fn find_matching_tab_config<'a>( + target: &str, + configs: &'a [crate::tab_configs::TabConfig], +) -> Option<&'a crate::tab_configs::TabConfig> { + let target_lower = target.to_lowercase(); + if let Some(matched) = configs + .iter() + .find(|c| c.name.to_lowercase() == target_lower) + { + return Some(matched); + } + + let stem = remove_extension(target).unwrap_or(target).to_lowercase(); + configs.iter().find(|c| { + c.source_path + .as_ref() + .and_then(|p| p.file_stem()) + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase() == stem) + .unwrap_or(false) + }) +} + /// Extract the `path` query parameter, expanding a leading `~` to the /// user's home directory. fn parse_tab_path(url: &Url) -> Option { @@ -1306,7 +1392,8 @@ fn validate_custom_uri(url: &Url) -> Result { | UriHost::Settings | UriHost::Mcp | UriHost::Codex - | UriHost::Linear => true, + | UriHost::Linear + | UriHost::TabConfig => true, // Auth and Home only allow the desktop redirect path UriHost::Auth | UriHost::Home => false, }; diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index 74efc952b..b63f7488b 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -17,7 +17,6 @@ use std::path::PathBuf; use warp_core::ui::theme::WarpTheme; use warpui::{Entity, ModelContext, SingletonEntity}; -#[cfg(test)] pub(crate) use imp::load_tab_configs; #[cfg(feature = "local_fs")] pub use imp::load_workflows; diff --git a/app/src/user_config/wasm.rs b/app/src/user_config/wasm.rs index 3dce4fa57..ca0b8d998 100644 --- a/app/src/user_config/wasm.rs +++ b/app/src/user_config/wasm.rs @@ -3,6 +3,7 @@ use std::path::Path; use warpui::ModelContext; use crate::launch_configs::launch_config::LaunchConfig; +use crate::tab_configs::{TabConfig, TabConfigError}; use crate::themes::theme::WarpThemeConfig; use crate::workflows::workflow::Workflow; @@ -39,3 +40,10 @@ pub fn load_launch_configs(_launch_config_path: &Path) -> Vec { // launch configs from any path. Default::default() } + +/// Loads all tab configs relative to the `tab_config_path`. +pub(crate) fn load_tab_configs(_tab_config_path: &Path) -> (Vec, Vec) { + // There's no local filesystem for wasm, so we'll never be able to retrieve + // tab configs from any path. + Default::default() +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 6af9ef532..49de76a82 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -6306,7 +6306,7 @@ impl Workspace { /// Opens a tab config, showing the param-fill modal when the config has parameters, /// or opening the tab directly when there are no parameters. - fn open_tab_config( + pub(crate) fn open_tab_config( &mut self, tab_config: crate::tab_configs::TabConfig, ctx: &mut ViewContext,