Skip to content

Routing and navigation

Routing in gpui-starter is built around two enums: Page defines what appears in the sidebar, and AppRoute adds sub-routes and deep link support. The whole system lives in src/shell/sidebar.rs and src/shell/route.rs. No router crate, no string matching, just exhaustive pattern matching the compiler can check.

If you are new to the project, start with the getting started guide to get the app running first. The architecture overview covers how routing fits into the broader app structure.

The sidebar is driven by Page in src/shell/sidebar.rs. Each variant corresponds to a top-level view:

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Page {
Home,
Form,
HttpLab,
HttpLabTesting,
Settings,
Notifications,
Diagnostics,
QueryDevTools,
QueryPlayground,
QueryDevToolsV2,
ErrorPlayground,
About,
}

Page provides three methods you will use everywhere:

MethodReturn typeWhat it does
title(&self)&'static strHuman-readable label for sidebar and header
icon(&self)IconNameIcon rendered next to the sidebar entry
all()&'static [Page]Ordered list used to build the sidebar menu

The sidebar iterates Page::all() to build its menu. Add a variant, update these three methods, and the sidebar picks it up automatically. No separate route config file to maintain.

src/shell/route.rs defines AppRoute, a two-level routing type. It wraps Page for standard navigation and adds sub-routes for specific destinations within a page:

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AppRoute {
Page(Page),
SettingsNotifications,
}

SettingsNotifications maps to Page::Settings through page_for_render(). That means the settings page renders, but your code can tell it is showing the notifications subsection specifically.

MethodReturn typeWhat it does
page(page)SelfConstructor for standard pages
page_for_render(&self)PageCollapses sub-routes to their parent page
title(&self)&'static strHeader title for the current route
to_url(&self)StringSerializes to a deep link URL
parse_deep_link(input)Result<Self, AppError>Parses a deep link string into a route

The default route is AppRoute::Page(Page::Home) via the Default impl.

AppRoot in src/shell/root.rs stores an active_route: AppRoute field. The unchecked_active_page_view method pattern-matches on the resolved Page and returns the corresponding entity as AnyView:

fn unchecked_active_page_view(&self) -> AnyView {
match self.active_route.page_for_render() {
Page::Home => self.home_page.clone().into(),
Page::Form => self.form_page.clone().into(),
Page::HttpLab => self.http_lab_page.clone().into(),
Page::HttpLabTesting => self.http_lab_testing_page.clone().into(),
Page::Settings => self.settings_page.clone().into(),
Page::Notifications => self.notifications_page.clone().into(),
Page::Diagnostics => self.diagnostics_page.clone().into(),
Page::ErrorPlayground => self.error_playground_page.clone().into(),
Page::QueryDevTools => self.query_devtools_page.clone().into(),
Page::QueryPlayground => self.query_playground_page.clone().into(),
Page::QueryDevToolsV2 => self.query_devtools_v2_page.clone().into(),
Page::About => self.about_page.clone().into(),
}
}

There is also an active_page_view method that wraps this with an error boundary check. If a render panic was detected since the last frame, it swaps in a RenderErrorPage fallback instead. The user can dismiss it with “Reload Page” or by navigating away. Read more about this pattern in the architecture doc.

set_route updates active_route, persists the new route to the config file, clears any active error boundary, and calls cx.notify() to trigger a re-render:

fn set_route(&mut self, route: AppRoute, cx: &mut Context<Self>) {
if self.active_route == route {
return; // no-op if already on this route
}
let route_url = route.to_url();
tracing::info!(route = ?route, route_url, "navigating");
self.active_route = route.clone();
self.render_error = false;
self.error_page = None;
crate::app_state::update_config(cx, |config| {
config.active_route = route;
});
cx.notify();
}

Because the active route is stored in AppConfig, the last viewed page is restored on the next launch. The config system is covered in the architecture overview.

Sidebar items call set_route on click:

SidebarMenuItem::new(page.title())
.icon(Icon::new(page.icon()).small())
.active(!self.render_error && active_page == page)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.set_route(AppRoute::page(page), cx);
}))

Note the !self.render_error && guard on the active state. When the error boundary is showing, no sidebar item highlights, which signals to the user that something went wrong on the current page.

gpui-starter binds Cmd+1 through Cmd+9 to jump directly to the first nine pages in Page::all(). This happens during AppRoot::new:

let pages = Page::all();
cx.bind_keys(
pages
.iter()
.enumerate()
.filter_map(|(i, _)| {
if i < 9 {
Some(KeyBinding::new(
&format!("cmd-{}", i + 1),
NavigateToPage(i),
None,
))
} else {
None
}
})
.collect::<Vec<_>>(),
);

The NavigateToPage action handler looks up the page at that index and calls set_route. There is also a RefreshPage action bound to the context menu on each sidebar item, which forces a re-render via cx.notify() without changing the route.

The command launcher provides another way to navigate: open it with Cmd+K and type a page name.

The app registers the gpui-starter:// URL scheme with the OS. When macOS opens a URL with this scheme, here is the flow:

  1. single_instance::preflight() inspects CLI args for any argument starting with gpui-starter://.
  2. If the app is already running, the deep link is forwarded to the primary instance via IPC (local socket) or a queue file fallback.
  3. The primary instance emits AppEventKind::DeepLinkReceived(link) into the global AppEventQueue.
  4. AppRoot observes the event queue and calls AppRoute::parse_deep_link on the URL:
cx.observe_global::<events::AppEventQueue>(|this, cx| {
for event in events::drain(cx) {
match event.kind {
AppEventKind::Navigate(route) => this.set_route(route, cx),
AppEventKind::DeepLinkReceived(link) => {
match AppRoute::parse_deep_link(&link) {
Ok(route) => this.set_route(route, cx),
Err(err) => events::emit_error(err, cx),
}
}
// ...
}
}
})
.detach();

parse_deep_link does not just pattern-match. It runs a validation pipeline before route matching:

  1. URL parsing via the url crate. Malformed input returns AppError::InvalidDeepLink.
  2. Scheme check against the APP_URL_SCHEME constant (gpui-starter).
  3. Host allowlist checked against VALID_HOSTS. Unknown hosts are rejected.
  4. Path segment validation that blocks traversal sequences (.., .) and forbidden characters (/, \, \0, <, >, |, ").
  5. Query parameter sanitization that strips control characters from both keys and values.

This matters because deep links come from outside the app. A malicious URL in a web page or email should not be able to inject control characters or path traversal sequences into your routing logic.

After validation, the host and path segments map to routes:

URLRoute
gpui-starter://homePage(Home)
gpui-starter://formPage(Form)
gpui-starter://httpPage(HttpLab)
gpui-starter://httplab-testingPage(HttpLabTesting)
gpui-starter://settingsPage(Settings)
gpui-starter://settings/notificationsSettingsNotifications
gpui-starter://notificationsPage(Notifications)
gpui-starter://diagnosticsPage(Diagnostics)
gpui-starter://error-playgroundPage(ErrorPlayground)
gpui-starter://query-devtoolsPage(QueryDevTools)
gpui-starter://query-playgroundPage(QueryPlayground)
gpui-starter://query-devtools-v2Page(QueryDevToolsV2)
gpui-starter://aboutPage(About)

URLs with an unsupported scheme or unknown host return AppError::InvalidDeepLink.

Adding a route requires changes in three files.

In src/shell/sidebar.rs, add the variant and update the three methods:

pub enum Page {
Home,
Form,
// ...existing variants...
Profile, // new
}

Update title, icon, and all to include the variant. The sidebar menu rebuilds from Page::all(), so the new entry appears automatically.

In src/shell/route.rs, add the URL mapping if you want deep link support:

// In to_url:
Self::Page(Page::Profile) => "gpui-starter://profile".to_string(),
// In VALID_HOSTS array:
const VALID_HOSTS: &[&str] = &[
"home", "form", /* ... */, "profile",
];
// In parse_deep_link match:
("profile", []) => Ok(Self::Page(Page::Profile)),

Do not skip adding the host to VALID_HOSTS. Without it, parse_deep_link rejects the URL before reaching the match arm.

In src/shell/root.rs, create an entity field on AppRoot, initialize it in new, and add a match arm in unchecked_active_page_view:

// Field on AppRoot:
profile_page: Entity<ProfilePage>,
// In AppRoot::new:
let profile_page = cx.new(|cx| ProfilePage::new(window, cx));
// In unchecked_active_page_view:
Page::Profile => self.profile_page.clone().into(),

That is it. Three files, three changes, and the compiler will tell you if you missed anything because the match is exhaustive.

The route module has a companion test file at src/shell/route.test.rs. It covers valid deep links, unknown hosts, path traversal attempts, and scheme validation. When you add a new route, add a test case for its deep link URL:

#[test]
fn parse_profile_deep_link() {
let route = AppRoute::parse_deep_link("gpui-starter://profile").unwrap();
assert_eq!(route, AppRoute::Page(Page::Profile));
}

For more on testing GPUI applications, see the testing guide.