Wire topbar tab history navigation

This commit is contained in:
2026-05-13 00:49:52 -04:00
parent 8cd5e969dc
commit 4cb7ff089e
5 changed files with 207 additions and 80 deletions
+53 -71
View File
@@ -3,8 +3,8 @@ use ely_design_system::{colors, spacing};
use ely_domain::{BrowserTab, ThemeMode}; use ely_domain::{BrowserTab, ThemeMode};
use gpui::{ use gpui::{
AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement,
SharedString, StatefulInteractiveElement, Styled, div, hsla, point, SharedString, StatefulInteractiveElement, Styled, div, hsla, point, prelude::FluentBuilder, px,
prelude::FluentBuilder, px, rgb, rgba, rgb, rgba,
}; };
use gpui_component::{IconName, input::Input}; use gpui_component::{IconName, input::Input};
@@ -27,36 +27,37 @@ pub(crate) fn render_topbar(
.flex_shrink_0() .flex_shrink_0()
.border_b_1() .border_b_1()
.border_color(rgba(colors::DIVIDER)) .border_color(rgba(colors::DIVIDER))
.when(sidebar_collapsed, |el| { .when(sidebar_collapsed, |el| el.child(render_command_bar_identity(snapshot, 56.0, true)))
el.child(render_command_bar_identity(snapshot, 56.0, true)) .child(render_nav_arrow(
}) "nav-back",
.child(render_nav_arrow("nav-back", IconName::ArrowLeft)) IconName::ArrowLeft,
.child(render_nav_arrow("nav-forward", IconName::ArrowRight)) active_tab.can_navigate_back(),
cx,
|shell, window, cx| shell.navigate_active_tab_back(window, cx),
))
.child(render_nav_arrow(
"nav-forward",
IconName::ArrowRight,
active_tab.can_navigate_forward(),
cx,
|shell, window, cx| shell.navigate_active_tab_forward(window, cx),
))
.child(render_omnibar(shell, active_tab, cx)) .child(render_omnibar(shell, active_tab, cx))
.child(render_topbar_action( .child(render_topbar_action("share-url", IconName::Copy, cx, |shell, window, cx| {
"share-url", shell.copy_active_tab_url(window, cx)
IconName::Copy, }))
cx, .child(render_topbar_action("open-downloads", IconName::Folder, cx, |shell, window, cx| {
|shell, window, cx| shell.copy_active_tab_url(window, cx), shell.open_downloads(window, cx)
)) }))
.child(render_topbar_action(
"open-downloads",
IconName::Folder,
cx,
|shell, window, cx| shell.open_downloads(window, cx),
))
.child(render_topbar_action( .child(render_topbar_action(
"toggle-theme", "toggle-theme",
theme_mode_icon(snapshot.appearance.theme_mode()), theme_mode_icon(snapshot.appearance.theme_mode()),
cx, cx,
|shell, _window, cx| shell.cycle_theme_mode(cx), |shell, _window, cx| shell.cycle_theme_mode(cx),
)) ))
.child(render_topbar_action( .child(render_topbar_action("open-menu", IconName::Menu, cx, |shell, window, cx| {
"open-menu", shell.open_internal_tab("ely://settings", window, cx)
IconName::Menu, }))
cx,
|shell, window, cx| shell.open_internal_tab("ely://settings", window, cx),
))
.into_any_element() .into_any_element()
} }
@@ -66,16 +67,11 @@ fn render_omnibar(
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
let favorite_active = active_tab.flags().favorite; let favorite_active = active_tab.flags().favorite;
let favorite_icon = if favorite_active { let favorite_icon = if favorite_active { IconName::Star } else { IconName::StarOff };
IconName::Star
} else {
IconName::StarOff
};
let input_value = shell.command_input.read(cx).value().to_string(); let input_value = shell.command_input.read(cx).value().to_string();
let active_url = active_tab.url().as_str().to_string(); let active_url = active_tab.url().as_str().to_string();
let show_styled = !input_value.is_empty() let show_styled =
&& input_value == active_url !input_value.is_empty() && input_value == active_url && active_url != "ely://new-tab";
&& active_url != "ely://new-tab";
let secure = active_url.starts_with("https://") || active_url.starts_with("ely://"); let secure = active_url.starts_with("https://") || active_url.starts_with("ely://");
div() div()
@@ -123,18 +119,11 @@ fn render_omnibar(
} }
fn render_omnibar_input(shell: &ElyShell) -> AnyElement { fn render_omnibar_input(shell: &ElyShell) -> AnyElement {
Input::new(&shell.command_input) Input::new(&shell.command_input).appearance(false).cleanable(true).into_any_element()
.appearance(false)
.cleanable(true)
.into_any_element()
} }
fn render_styled_url(active_tab: &BrowserTab) -> AnyElement { fn render_styled_url(active_tab: &BrowserTab) -> AnyElement {
let host = active_tab let host = active_tab.url().host().map(|host| host.to_string()).unwrap_or_default();
.url()
.host()
.map(|host| host.to_string())
.unwrap_or_default();
let url = active_tab.url().as_str(); let url = active_tab.url().as_str();
let path_start = url.find("://").map(|prefix| prefix + 3).unwrap_or(0); let path_start = url.find("://").map(|prefix| prefix + 3).unwrap_or(0);
let from_path = &url[path_start..]; let from_path = &url[path_start..];
@@ -148,32 +137,14 @@ fn render_styled_url(active_tab: &BrowserTab) -> AnyElement {
.items_center() .items_center()
.gap(px(2.0)) .gap(px(2.0))
.text_size(px(13.0)) .text_size(px(13.0))
.child( .child(div().font_weight(FontWeight(500.0)).text_color(rgb(colors::INK)).child(host))
div() .child(div().min_w_0().truncate().text_color(rgb(colors::INK_3)).child(path))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::INK))
.child(host),
)
.child(
div()
.min_w_0()
.truncate()
.text_color(rgb(colors::INK_3))
.child(path),
)
.into_any_element() .into_any_element()
} }
fn render_lock_or_search(secure: bool, show_styled: bool) -> AnyElement { fn render_lock_or_search(secure: bool, show_styled: bool) -> AnyElement {
let icon = if show_styled && !secure { let icon = if show_styled && !secure { IconName::Globe } else { IconName::Search };
IconName::Globe div().text_color(rgb(colors::INK_3)).child(icon).into_any_element()
} else {
IconName::Search
};
div()
.text_color(rgb(colors::INK_3))
.child(icon)
.into_any_element()
} }
fn render_omnibar_chip<F>( fn render_omnibar_chip<F>(
@@ -203,12 +174,17 @@ where
.into_any_element() .into_any_element()
} }
/// Topbar nav arrow placeholder. Per-tab back/forward history is not yet fn render_nav_arrow<F>(
/// wired through `BrowserCore`, so the buttons render in the design's id: &'static str,
/// `disabled` state — visible at INK_5, no hover, no cursor pointer — icon: IconName,
/// to honor the "no fake handlers, no mockup" rule. When real history enabled: bool,
/// navigation lands the caller can flip these to a clickable variant. cx: &mut Context<ElyShell>,
fn render_nav_arrow(id: &'static str, icon: IconName) -> AnyElement { handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{
let color = if enabled { colors::INK_3 } else { colors::INK_5 };
div() div()
.id(SharedString::from(id)) .id(SharedString::from(id))
.size(px(30.0)) .size(px(30.0))
@@ -216,7 +192,13 @@ fn render_nav_arrow(id: &'static str, icon: IconName) -> AnyElement {
.flex() .flex()
.items_center() .items_center()
.justify_center() .justify_center()
.text_color(rgb(colors::INK_5)) .text_color(rgb(color))
.when(enabled, |el| {
el.cursor_pointer()
.hover(|style| style.bg(rgba(OMNIBAR_BG)).text_color(rgb(colors::INK)))
.active(|style| style.opacity(0.82))
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx)))
})
.child(icon) .child(icon)
.into_any_element() .into_any_element()
} }
+23 -3
View File
@@ -78,6 +78,28 @@ impl ElyShell {
} }
} }
pub(super) fn navigate_active_tab_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let ShellState::Ready(core) = &mut self.state
&& core.navigate_active_tab_back().is_ok_and(|changed| changed)
{
self.sync_address_input(window, cx);
cx.notify();
}
}
pub(super) fn navigate_active_tab_forward(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let ShellState::Ready(core) = &mut self.state
&& core.navigate_active_tab_forward().is_ok_and(|changed| changed)
{
self.sync_address_input(window, cx);
cx.notify();
}
}
/// Spawn a fresh tab for `url`. Reserved for "+ New Tab" buttons /// Spawn a fresh tab for `url`. Reserved for "+ New Tab" buttons
/// and the deep-link router — anything that explicitly wants a /// and the deep-link router — anything that explicitly wants a
/// new sibling tab rather than navigating in place. /// new sibling tab rather than navigating in place.
@@ -162,9 +184,7 @@ impl ElyShell {
return; return;
}; };
let needle = stripped.trim().to_lowercase(); let needle = stripped.trim().to_lowercase();
let rows = crate::shell::chrome::command_match::visible_command_rows( let rows = crate::shell::chrome::command_match::visible_command_rows(&snapshot, &needle);
&snapshot, &needle,
);
if rows.is_empty() { if rows.is_empty() {
return; return;
} }
+35 -1
View File
@@ -28,7 +28,7 @@ impl BrowserCore {
.iter() .iter()
.position(|tab| tab.id() == &active_id) .position(|tab| tab.id() == &active_id)
.ok_or_else(|| CoreError::TabNotFound { id: active_id.clone() })?; .ok_or_else(|| CoreError::TabNotFound { id: active_id.clone() })?;
self.tabs[tab_index].set_url(url); self.tabs[tab_index].navigate_to(url);
self.tabs[tab_index].mark_ready(); self.tabs[tab_index].mark_ready();
let snapshot_tab = self.tabs[tab_index].clone(); let snapshot_tab = self.tabs[tab_index].clone();
self.record_history_entry(&snapshot_tab); self.record_history_entry(&snapshot_tab);
@@ -36,6 +36,14 @@ impl BrowserCore {
Ok(()) Ok(())
} }
pub fn navigate_active_tab_back(&mut self) -> Result<bool, CoreError> {
self.navigate_active_tab_history(TabHistoryDirection::Back)
}
pub fn navigate_active_tab_forward(&mut self) -> Result<bool, CoreError> {
self.navigate_active_tab_history(TabHistoryDirection::Forward)
}
pub fn open_tab(&mut self, url: UrlText) -> TabId { pub fn open_tab(&mut self, url: UrlText) -> TabId {
let tab = self.build_tab(url); let tab = self.build_tab(url);
let tab_id = tab.id().clone(); let tab_id = tab.id().clone();
@@ -369,6 +377,27 @@ impl BrowserCore {
self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab) self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)
} }
fn navigate_active_tab_history(
&mut self,
direction: TabHistoryDirection,
) -> Result<bool, CoreError> {
let active_id = self.active_tab_id.clone();
let tab_index = self.active_tab_index()?;
let navigated_url = match direction {
TabHistoryDirection::Back => self.tabs[tab_index].navigate_back(),
TabHistoryDirection::Forward => self.tabs[tab_index].navigate_forward(),
};
let Some(_url) = navigated_url else {
return Ok(false);
};
self.tabs[tab_index].mark_ready();
let snapshot_tab = self.tabs[tab_index].clone();
self.record_history_entry(&snapshot_tab);
self.record_tab_activity(&active_id, SystemTime::now());
Ok(true)
}
pub(super) fn build_tab(&self, url: UrlText) -> BrowserTab { pub(super) fn build_tab(&self, url: UrlText) -> BrowserTab {
self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url) self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url)
.with_parent_tab_id(self.active_tab_id.clone()) .with_parent_tab_id(self.active_tab_id.clone())
@@ -425,3 +454,8 @@ impl BrowserCore {
) )
} }
} }
enum TabHistoryDirection {
Back,
Forward,
}
@@ -0,0 +1,53 @@
use std::error::Error;
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::UrlText;
#[test]
fn active_tab_tracks_back_and_forward_navigation() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
core.navigate_active_tab(UrlText::parse("https://example.com/a")?)?;
core.navigate_active_tab(UrlText::parse("https://example.com/b")?)?;
assert!(core.active_tab()?.can_navigate_back());
assert!(!core.active_tab()?.can_navigate_forward());
assert!(core.navigate_active_tab_back()?);
assert_eq!(core.active_tab()?.url().as_str(), "https://example.com/a");
assert!(core.active_tab()?.can_navigate_back());
assert!(core.active_tab()?.can_navigate_forward());
assert!(core.navigate_active_tab_forward()?);
assert_eq!(core.active_tab()?.url().as_str(), "https://example.com/b");
assert!(core.active_tab()?.can_navigate_back());
assert!(!core.active_tab()?.can_navigate_forward());
Ok(())
}
#[test]
fn new_navigation_clears_forward_stack() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
core.navigate_active_tab(UrlText::parse("https://example.com/a")?)?;
core.navigate_active_tab(UrlText::parse("https://example.com/b")?)?;
assert!(core.navigate_active_tab_back()?);
core.navigate_active_tab(UrlText::parse("https://example.com/c")?)?;
assert_eq!(core.active_tab()?.url().as_str(), "https://example.com/c");
assert!(core.active_tab()?.can_navigate_back());
assert!(!core.active_tab()?.can_navigate_forward());
Ok(())
}
#[test]
fn empty_history_navigation_keeps_active_url() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let url = core.active_tab()?.url().clone();
assert!(!core.navigate_active_tab_back()?);
assert!(!core.navigate_active_tab_forward()?);
assert_eq!(core.active_tab()?.url(), &url);
Ok(())
}
+43 -5
View File
@@ -31,6 +31,8 @@ pub struct BrowserTab {
profile_id: ProfileId, profile_id: ProfileId,
title: String, title: String,
url: UrlText, url: UrlText,
back_stack: Vec<UrlText>,
forward_stack: Vec<UrlText>,
favicon_key: Option<String>, favicon_key: Option<String>,
parent_tab_id: Option<TabId>, parent_tab_id: Option<TabId>,
state: TabState, state: TabState,
@@ -60,6 +62,8 @@ impl BrowserTab {
profile_id, profile_id,
title: title.into(), title: title.into(),
url, url,
back_stack: Vec::new(),
forward_stack: Vec::new(),
favicon_key: None, favicon_key: None,
parent_tab_id: None, parent_tab_id: None,
state: TabState::Ready, state: TabState::Ready,
@@ -116,6 +120,16 @@ impl BrowserTab {
&self.url &self.url
} }
#[must_use]
pub fn can_navigate_back(&self) -> bool {
!self.back_stack.is_empty()
}
#[must_use]
pub fn can_navigate_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
#[must_use] #[must_use]
pub fn favicon_key(&self) -> Option<&str> { pub fn favicon_key(&self) -> Option<&str> {
self.favicon_key.as_deref() self.favicon_key.as_deref()
@@ -271,15 +285,39 @@ impl BrowserTab {
self.sync_enabled = sync_enabled; self.sync_enabled = sync_enabled;
} }
/// Replace this tab's URL in place. Used for in-tab navigation /// Replace this tab's URL directly while preserving navigation
/// (clicking a link, picking a settings sub-page, etc.) where the /// stacks. Title stays as set; callers can re-derive it from the
/// active tab should follow the user instead of spawning a new /// new URL when needed.
/// one for every URL change. Title stays as set; the caller can
/// re-derive it from the new URL if it wants to.
pub fn set_url(&mut self, url: UrlText) { pub fn set_url(&mut self, url: UrlText) {
self.url = url; self.url = url;
self.last_active_at = SystemTime::now(); self.last_active_at = SystemTime::now();
} }
/// Navigate in place and record the previous URL in this tab's
/// back stack.
pub fn navigate_to(&mut self, url: UrlText) {
if self.url != url {
self.back_stack.push(self.url.clone());
self.forward_stack.clear();
}
self.set_url(url);
}
pub fn navigate_back(&mut self) -> Option<UrlText> {
let target = self.back_stack.pop()?;
let current = std::mem::replace(&mut self.url, target);
self.forward_stack.push(current);
self.last_active_at = SystemTime::now();
Some(self.url.clone())
}
pub fn navigate_forward(&mut self) -> Option<UrlText> {
let target = self.forward_stack.pop()?;
let current = std::mem::replace(&mut self.url, target);
self.back_stack.push(current);
self.last_active_at = SystemTime::now();
Some(self.url.clone())
}
} }
pub fn validate_zoom_percent(value: u16) -> Result<u16, DomainError> { pub fn validate_zoom_percent(value: u16) -> Result<u16, DomainError> {