Skip to content

Commit 676c882

Browse files
szguptaoz-agentacarl005
authored
Add warp://settings deeplink entrypoints (open, search, scroll-to-widget) (#13232)
Demo: https://www.loom.com/share/d26cdf004faa4aee9c128f77dc0b2af8 ## Description Adds a `warp://settings` deep link family so settings can be opened directly from a URL: - `warp://settings` - opens a settings tab on the default page. - `warp://settings?q=<query>` - opens settings with the search bar pre-filled and the page list filtered. - `warp://settings?widget=<widget_id>` - opens settings scrolled to (and highlighting) a specific widget. **Why:** lets external surfaces (docs, in-app links, support flows) deep-link users straight to the relevant setting. **How:** the existing `UriHost::Settings` handler now treats an empty trailing path segment as "no sub-page" and routes the bare host plus the `q`/`widget` query params (precedence: `widget` > `q` > existing simple sub-pages > bare default). A new `OpenSettingsArgs` enum maps to the existing workspace actions (`ShowSettings`, `ShowSettingsPageWithSearch`, `ScrollToSettingsWidget`) via two new `root_view` actions. `?widget=` resolves through an allowlist (`settings_widget_deeplink_target`) of stable slugs to `(SettingsSection, &'static str)` so internal Rust type names aren't exposed as the public URL contract; seeded with `global_hotkey`, `cli_agents`, and `custom_router`. Also fixed `open_settings_pane` so a `?q=` search applies even when a settings tab is already open. ## Linked Issue N/A - no linked issue. ## Testing Added unit tests in `app/src/uri/uri_tests.rs`: - `parse_settings_search_query` - present / empty / URL-encoded / missing `q` - `settings_widget_deeplink_target` - known slugs (incl. `custom_router`) and unknown/empty - `settings_section_for_simple_subpage` - regression for existing sub-pages Ran `./script/format` and `cargo clippy -p warp --all-targets --tests -- -D warnings` (clean), plus the new unit tests. - [ ] I have manually tested my changes locally with `./script/run` > Not manually run via `./script/run`; covered by unit tests. Routing-only change with no new UI layout, so no screenshots. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode ## Warp artifacts - Conversation: https://app.warp.dev/conversation/3d208974-67e1-44da-8157-12a7e7e2ef55 - Plan: https://app.warp.dev/drive/notebook/1soy0YOddbTETZH4fg3IV3 CHANGELOG-IMPROVEMENT: Added `warp://settings` deep links - open settings, pre-fill the search bar with `?q=`, or jump to a specific setting with `?widget=`. Co-Authored-By: Oz <oz-agent@warp.dev> --------- Co-authored-by: Oz <oz-agent@warp.dev> Co-authored-by: Andy <8334252+acarl005@users.noreply.github.com>
1 parent fd3ebd6 commit 676c882

7 files changed

Lines changed: 260 additions & 60 deletions

File tree

app/src/root_view.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ use crate::terminal::shell::ShellType;
8686
use crate::terminal::view::{cell_size_and_padding, TerminalAction};
8787
use crate::themes::onboarding_theme_picker_themes;
8888
use crate::themes::theme::{AnsiColorIdentifier, Blend, Fill, ThemeKind, WarpThemeConfig};
89-
use crate::uri::OpenMCPSettingsArgs;
89+
use crate::uri::{OpenMCPSettingsArgs, OpenSettingsArgs};
9090
use crate::util::bindings::{self, is_binding_pty_compliant};
9191
use crate::util::traffic_lights::{traffic_light_data, TrafficLightData, TrafficLightMouseStates};
9292
use crate::view_components::DismissibleToast;
@@ -387,6 +387,15 @@ pub fn init(app: &mut AppContext) {
387387
RootView::open_settings_page_in_existing_window,
388388
);
389389

390+
app.add_global_action(
391+
"root_view:open_settings_in_new_window",
392+
open_settings_in_new_window,
393+
);
394+
app.add_action(
395+
"root_view:open_settings_in_existing_window",
396+
RootView::open_settings_in_existing_window,
397+
);
398+
390399
app.add_global_action(
391400
"root_view:open_mcp_settings_in_new_window",
392401
open_mcp_settings_in_new_window,
@@ -980,6 +989,34 @@ fn open_settings_page_in_new_window(section: &SettingsSection, ctx: &mut AppCont
980989
});
981990
}
982991

992+
/// Maps a `warp://settings` deeplink to the workspace action that opens it.
993+
fn workspace_action_for_open_settings(args: &OpenSettingsArgs) -> WorkspaceAction {
994+
match args {
995+
OpenSettingsArgs::Default => WorkspaceAction::ShowSettings,
996+
OpenSettingsArgs::Search { query } => WorkspaceAction::ShowSettingsPageWithSearch {
997+
search_query: query.clone(),
998+
section: None,
999+
},
1000+
OpenSettingsArgs::Widget { page, widget_id } => WorkspaceAction::ScrollToSettingsWidget {
1001+
page: *page,
1002+
widget_id,
1003+
},
1004+
}
1005+
}
1006+
1007+
fn open_settings_in_new_window(args: &OpenSettingsArgs, ctx: &mut AppContext) {
1008+
let action = workspace_action_for_open_settings(args);
1009+
let root_handle = open_new_window_get_handles(None, ctx).1;
1010+
root_handle.update(ctx, |root_view, ctx| {
1011+
if let AuthOnboardingState::Terminal(workspace_view_handle) =
1012+
&root_view.auth_onboarding_state
1013+
{
1014+
let window_id = ctx.window_id();
1015+
ctx.dispatch_typed_action_for_view(window_id, workspace_view_handle.id(), &action);
1016+
}
1017+
});
1018+
}
1019+
9831020
/// MCP servers need to wait for initial load to complete, so we have this action in addition
9841021
/// to the general-purpose [`open_settings_page_in_new_window`].
9851022
fn open_mcp_settings_in_new_window(args: &OpenMCPSettingsArgs, ctx: &mut AppContext) {
@@ -2803,6 +2840,22 @@ impl RootView {
28032840
true
28042841
}
28052842

2843+
pub fn open_settings_in_existing_window(
2844+
&mut self,
2845+
args: &OpenSettingsArgs,
2846+
ctx: &mut ViewContext<Self>,
2847+
) -> bool {
2848+
let window_id = ctx.window_id();
2849+
if let AuthOnboardingState::Terminal(handle) = &self.auth_onboarding_state {
2850+
let action = workspace_action_for_open_settings(args);
2851+
ctx.dispatch_typed_action_for_view(window_id, handle.id(), &action);
2852+
ctx.windows().show_window_and_focus_app(window_id);
2853+
} else {
2854+
log::error!("Auth not complete before trying to open settings");
2855+
}
2856+
true
2857+
}
2858+
28062859
/// Opens the MCP servers settings page in an existing window, optionally triggering auto-install.
28072860
/// Waits for `initial_load_complete` before opening so gallery data is available for autoinstall.
28082861
pub fn open_mcp_settings_in_existing_window(

app/src/settings_view/ai_page.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9389,6 +9389,13 @@ impl SettingsWidget for AwsBedrockWidget {
93899389
}
93909390
}
93919391

9392+
/// Stable `&'static str` id for the custom model routers settings widget,
9393+
/// exposed for the `warp://settings?widget=custom_router` deeplink (see
9394+
/// `settings_widget_deeplink_target`).
9395+
pub(crate) fn custom_model_routers_widget_id() -> &'static str {
9396+
CustomModelRoutersWidget::static_widget_id()
9397+
}
9398+
93929399
#[derive(Default)]
93939400
struct CustomModelRoutersWidget;
93949401

app/src/settings_view/features_page.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5587,6 +5587,13 @@ impl SettingsWidget for ExtraMetaKeysWidget {
55875587
}
55885588
}
55895589

5590+
/// Stable `&'static str` id for the global-hotkey settings widget, exposed for
5591+
/// the `warp://settings?widget=global_hotkey` deeplink (see
5592+
/// `settings_widget_deeplink_target`).
5593+
pub(crate) fn global_hotkey_widget_id() -> &'static str {
5594+
GlobalHotkeyWidget::static_widget_id()
5595+
}
5596+
55905597
#[derive(Default)]
55915598
struct GlobalHotkeyWidget {}
55925599

app/src/settings_view/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ mod warpify_page;
118118

119119
#[cfg(not(target_family = "wasm"))]
120120
pub(crate) use ai_page::cli_agent_settings_widget_id;
121+
pub(crate) use ai_page::custom_model_routers_widget_id;
121122
pub use billing_and_usage_page::create_discount_badge;
122123
pub use code_page::CodeSettingsPageView;
123124
pub use features_page::FeaturesPageAction;
@@ -411,6 +412,29 @@ impl FromStr for SettingsSection {
411412
}
412413
}
413414

415+
/// Resolves a stable, friendly deeplink slug (used by
416+
/// `warp://settings?widget=<slug>`) to the settings page and `&'static str`
417+
/// widget id it should scroll to.
418+
///
419+
/// Only allowlisted widgets are linkable, so the public URL contract stays
420+
/// stable and internal widget identifiers (Rust type names) are not exposed.
421+
/// Add an entry here to make a new widget deep-linkable.
422+
pub fn settings_widget_deeplink_target(slug: &str) -> Option<(SettingsSection, &'static str)> {
423+
match slug {
424+
"global_hotkey" => Some((
425+
SettingsSection::Features,
426+
features_page::global_hotkey_widget_id(),
427+
)),
428+
"custom_router" => Some((SettingsSection::WarpAgent, custom_model_routers_widget_id())),
429+
#[cfg(not(target_family = "wasm"))]
430+
"cli_agents" => Some((
431+
SettingsSection::ThirdPartyCLIAgents,
432+
cli_agent_settings_widget_id(),
433+
)),
434+
_ => None,
435+
}
436+
}
437+
414438
pub struct DisplayCount(pub usize);
415439

416440
impl Entity for DisplayCount {

app/src/uri/mod.rs

Lines changed: 118 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ use crate::root_view::{
3333
};
3434
use crate::server::ids::ServerId;
3535
use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent};
36-
use crate::settings_view::{OpenTeamsSettingsModalArgs, SettingsSection};
36+
use crate::settings_view::{
37+
settings_widget_deeplink_target, OpenTeamsSettingsModalArgs, SettingsSection,
38+
};
3739
use crate::tab_configs::TabConfig;
3840
use crate::user_config::{load_launch_configs, load_tab_configs, tab_configs_dir};
3941
use crate::util::openable_file_type::{
@@ -60,6 +62,20 @@ pub struct OpenMCPSettingsArgs {
6062
pub autoinstall: Option<String>,
6163
}
6264

65+
/// Args for the `warp://settings` deeplink family, dispatched to the
66+
/// `root_view:open_settings_in_{existing,new}_window` actions.
67+
pub enum OpenSettingsArgs {
68+
/// `warp://settings` — open a settings tab on the default page.
69+
Default,
70+
/// `warp://settings?q=<query>` — open settings with the search bar pre-filled.
71+
Search { query: String },
72+
/// `warp://settings?widget=<widget_id>` — open settings scrolled to a widget.
73+
Widget {
74+
page: SettingsSection,
75+
widget_id: &'static str,
76+
},
77+
}
78+
6379
/// Source query parameter value indicating auth was initiated from cloud agent setup.
6480
/// Used to skip opening settings page after GitHub auth completes.
6581
pub const CLOUD_SETUP_SOURCE: &str = "cloud_setup";
@@ -337,87 +353,133 @@ impl UriHost {
337353
}
338354
UriHost::Settings => {
339355
// We support opening different settings pages through URI:
356+
// - warp://settings - opens a settings tab on the default page
357+
// - warp://settings?q={query} - opens settings with the search bar pre-filled
358+
// - warp://settings?widget={widget_id} - opens settings scrolled to a widget
340359
// - warp://settings/teams?invite={email} - opens team settings with invite modal
341360
// - warp://settings/billing_and_usage - opens billing and usage settings page
342361
// - warp://settings/environments - opens environments settings page
343362
// - warp://settings/mcp - opens MCP servers settings page
344363
// - warp://settings/platform - opens platform settings page
345364
// - warp://settings/appearance - opens appearance settings page (themes, fonts, etc.)
346365
// - warp://settings/warp_agent - opens the Warp Agent settings page (inference / API keys)
366+
let query_string: HashMap<_, _> = url.query_pairs().collect();
367+
// A bare `warp://settings` (or a trailing slash) yields an empty path
368+
// segment; treat that as "no sub-page" so the query-param routing below
369+
// handles it.
347370
let settings_sub_page: Option<String> = url
348371
.path_segments()
349372
.into_iter()
350373
.flatten()
351374
.last()
375+
.filter(|s| !s.is_empty())
352376
.map(|s| s.to_string());
353-
let query_string: HashMap<_, _> = url.query_pairs().collect();
354377

355-
if let Some(settings_sub_page) = settings_sub_page {
356-
match settings_sub_page.as_str() {
357-
"teams" => {
358-
let invite_email = query_string.get("invite").map(|s| s.to_string());
359-
let args = OpenTeamsSettingsModalArgs { invite_email };
378+
match settings_sub_page.as_deref() {
379+
Some("teams") => {
380+
let invite_email = query_string.get("invite").map(|s| s.to_string());
381+
let args = OpenTeamsSettingsModalArgs { invite_email };
382+
dispatch_action_in_new_or_existing_window(
383+
primary_window_id,
384+
"root_view:open_team_settings_with_email_invite_in_existing_window",
385+
"root_view:open_team_settings_with_email_invite_in_new_window",
386+
&args,
387+
ctx,
388+
);
389+
}
390+
Some("environments") => {
391+
// Notify that GitHub auth completed so views can refresh
392+
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
393+
notifier.notify_auth_completed(ctx);
394+
});
395+
396+
// Open settings page unless auth was initiated from cloud setup
397+
// (cloud setup users should stay on their current page)
398+
let source = query_string.get("source").map(|s| s.as_ref());
399+
let skip_settings = source == Some(CLOUD_SETUP_SOURCE);
400+
if !skip_settings {
360401
dispatch_action_in_new_or_existing_window(
361402
primary_window_id,
362-
"root_view:open_team_settings_with_email_invite_in_existing_window",
363-
"root_view:open_team_settings_with_email_invite_in_new_window",
364-
&args,
403+
"root_view:open_settings_page_in_existing_window",
404+
"root_view:open_settings_page_in_new_window",
405+
&SettingsSection::CloudEnvironments,
365406
ctx,
366407
);
367408
}
368-
"environments" => {
369-
// Notify that GitHub auth completed so views can refresh
370-
GitHubAuthNotifier::handle(ctx).update(ctx, |notifier, ctx| {
371-
notifier.notify_auth_completed(ctx);
372-
});
373-
374-
// Open settings page unless auth was initiated from cloud setup
375-
// (cloud setup users should stay on their current page)
376-
let source = query_string.get("source").map(|s| s.as_ref());
377-
let skip_settings = source == Some(CLOUD_SETUP_SOURCE);
378-
if !skip_settings {
379-
dispatch_action_in_new_or_existing_window(
380-
primary_window_id,
381-
"root_view:open_settings_page_in_existing_window",
382-
"root_view:open_settings_page_in_new_window",
383-
&SettingsSection::CloudEnvironments,
384-
ctx,
385-
);
386-
}
387-
}
388-
"mcp" => {
389-
// warp://settings/mcp?autoinstall=<name> auto-installs a gallery MCP server.
390-
// The value is matched case-insensitively against gallery titles.
391-
let autoinstall =
392-
query_string.get("autoinstall").map(|v| v.to_string());
393-
let args = OpenMCPSettingsArgs { autoinstall };
409+
}
410+
Some("mcp") => {
411+
// warp://settings/mcp?autoinstall=<name> auto-installs a gallery MCP server.
412+
// The value is matched case-insensitively against gallery titles.
413+
let autoinstall = query_string.get("autoinstall").map(|v| v.to_string());
414+
let args = OpenMCPSettingsArgs { autoinstall };
415+
dispatch_action_in_new_or_existing_window(
416+
primary_window_id,
417+
"root_view:open_mcp_settings_in_existing_window",
418+
"root_view:open_mcp_settings_in_new_window",
419+
&args,
420+
ctx,
421+
);
422+
}
423+
// No special sub-page: route the bare host, the `q` (search) and
424+
// `widget` (scroll-to) query params, and the simple section
425+
// sub-pages (e.g. billing_and_usage, platform, appearance,
426+
// warp_agent) resolved via `settings_section_for_simple_subpage`.
427+
maybe_simple_subpage => {
428+
let simple_section =
429+
maybe_simple_subpage.and_then(settings_section_for_simple_subpage);
430+
// Pull the non-empty `q` search query out of the already
431+
// parsed pairs to pre-fill the settings search bar.
432+
let search_query = query_string
433+
.get("q")
434+
.map(|query| query.to_string())
435+
.filter(|query| !query.is_empty());
436+
let widget_target = query_string
437+
.get("widget")
438+
.and_then(|slug| settings_widget_deeplink_target(slug));
439+
440+
if let Some((page, widget_id)) = widget_target {
441+
// `?widget=` scrolls to a specific widget; it takes
442+
// precedence over `?q=` since searching would filter the
443+
// target widget out of view.
444+
let args = OpenSettingsArgs::Widget { page, widget_id };
394445
dispatch_action_in_new_or_existing_window(
395446
primary_window_id,
396-
"root_view:open_mcp_settings_in_existing_window",
397-
"root_view:open_mcp_settings_in_new_window",
447+
"root_view:open_settings_in_existing_window",
448+
"root_view:open_settings_in_new_window",
398449
&args,
399450
ctx,
400451
);
401-
}
402-
// Subpages that open a settings section directly with no extra
403-
// parameters (e.g. billing_and_usage, platform, appearance,
404-
// warp_agent) are resolved via `settings_section_for_simple_subpage`.
405-
other => {
406-
if let Some(section) = settings_section_for_simple_subpage(other) {
407-
dispatch_action_in_new_or_existing_window(
408-
primary_window_id,
409-
"root_view:open_settings_page_in_existing_window",
410-
"root_view:open_settings_page_in_new_window",
411-
&section,
412-
ctx,
413-
);
414-
} else {
415-
log::warn!("Failed to open settings pane with uri={url}");
416-
}
452+
} else if let Some(query) = search_query {
453+
let args = OpenSettingsArgs::Search { query };
454+
dispatch_action_in_new_or_existing_window(
455+
primary_window_id,
456+
"root_view:open_settings_in_existing_window",
457+
"root_view:open_settings_in_new_window",
458+
&args,
459+
ctx,
460+
);
461+
} else if let Some(section) = simple_section {
462+
dispatch_action_in_new_or_existing_window(
463+
primary_window_id,
464+
"root_view:open_settings_page_in_existing_window",
465+
"root_view:open_settings_page_in_new_window",
466+
&section,
467+
ctx,
468+
);
469+
} else if maybe_simple_subpage.is_none() {
470+
// Bare `warp://settings` opens the default settings page.
471+
let args = OpenSettingsArgs::Default;
472+
dispatch_action_in_new_or_existing_window(
473+
primary_window_id,
474+
"root_view:open_settings_in_existing_window",
475+
"root_view:open_settings_in_new_window",
476+
&args,
477+
ctx,
478+
);
479+
} else {
480+
log::warn!("Failed to open settings pane: unrecognized sub-page");
417481
}
418482
}
419-
} else {
420-
log::warn!("Failed to open settings pane with uri={url}");
421483
}
422484
}
423485
UriHost::Home => {

0 commit comments

Comments
 (0)