Root cause of "settings opens new tab for every click": every internal navigation went through `open_internal_tab → open_url → core.open_tab(url)`, and `open_tab` unconditionally inserts a new `BrowserTab`. So three settings sub-page clicks left four tabs in the sidebar, which is the screenshot the user keeps sending. Real browsers navigate the active tab in place for in-app links and spawn new tabs only on `+ New Tab` (or Cmd-click). Wire it through: * `BrowserTab::set_url(url)` mutates the tab's URL and bumps `last_active_at`. Title stays put — the page renderer can refresh it from the new URL. * `BrowserCore::navigate_active_tab(url)` finds the active tab, calls `set_url`, marks it Ready, records the history entry, and bumps activity. Returns `TabNotFound` if there's no active tab. * `ElyShell::navigate_active_tab` calls the core method and falls back to `open_tab` if there's no active tab to navigate. The shell's `open_internal_tab` (used by settings nav, home pills, sidebar Settings + Profile rows, command-overlay routes, etc.) now routes through this in-place path. * `open_url` keeps the explicit "spawn a new tab" semantics for `+ New Tab` and the deep-link router. Settings, plugin marketplace, history, profile picker — every sidebar nav now stays in one tab. cargo test --workspace: 440 passed, 0 failed.
335 lines
8.0 KiB
Rust
335 lines
8.0 KiB
Rust
use std::time::SystemTime;
|
|
|
|
use crate::{DomainError, ProfileId, SpaceId, SplitId, TabGroupId, TabId, UrlText};
|
|
|
|
pub const DEFAULT_ZOOM_PERCENT: u16 = 100;
|
|
pub const MIN_ZOOM_PERCENT: u16 = 25;
|
|
pub const MAX_ZOOM_PERCENT: u16 = 500;
|
|
pub const ZOOM_PERCENT_STEP: u16 = 10;
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum TabState {
|
|
Loading,
|
|
Ready,
|
|
Crashed,
|
|
Discarded,
|
|
Archived,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct TabFlags {
|
|
pub pinned: bool,
|
|
pub favorite: bool,
|
|
pub muted: bool,
|
|
pub unread: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct BrowserTab {
|
|
id: TabId,
|
|
space_id: SpaceId,
|
|
profile_id: ProfileId,
|
|
title: String,
|
|
url: UrlText,
|
|
favicon_key: Option<String>,
|
|
parent_tab_id: Option<TabId>,
|
|
state: TabState,
|
|
flags: TabFlags,
|
|
group_id: Option<TabGroupId>,
|
|
split_id: Option<SplitId>,
|
|
sort_key: u64,
|
|
sync_enabled: bool,
|
|
zoom_percent: u16,
|
|
created_at: SystemTime,
|
|
last_active_at: SystemTime,
|
|
}
|
|
|
|
impl BrowserTab {
|
|
#[must_use]
|
|
pub fn new(
|
|
id: TabId,
|
|
space_id: SpaceId,
|
|
profile_id: ProfileId,
|
|
title: impl Into<String>,
|
|
url: UrlText,
|
|
) -> Self {
|
|
let created_at = SystemTime::now();
|
|
Self {
|
|
id,
|
|
space_id,
|
|
profile_id,
|
|
title: title.into(),
|
|
url,
|
|
favicon_key: None,
|
|
parent_tab_id: None,
|
|
state: TabState::Ready,
|
|
flags: TabFlags::default(),
|
|
group_id: None,
|
|
split_id: None,
|
|
sort_key: 0,
|
|
sync_enabled: true,
|
|
zoom_percent: DEFAULT_ZOOM_PERCENT,
|
|
created_at,
|
|
last_active_at: created_at,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_parent_tab_id(mut self, parent_tab_id: TabId) -> Self {
|
|
self.parent_tab_id = Some(parent_tab_id);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_sort_key(mut self, sort_key: u64) -> Self {
|
|
self.sort_key = sort_key;
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn id(&self) -> &TabId {
|
|
&self.id
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn space_id(&self) -> &SpaceId {
|
|
&self.space_id
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn profile_id(&self) -> &ProfileId {
|
|
&self.profile_id
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn title(&self) -> &str {
|
|
&self.title
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn unread_count(&self) -> u32 {
|
|
parse_title_unread_count(&self.title)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn url(&self) -> &UrlText {
|
|
&self.url
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn favicon_key(&self) -> Option<&str> {
|
|
self.favicon_key.as_deref()
|
|
}
|
|
|
|
pub fn set_favicon_key(&mut self, favicon_key: impl Into<String>) -> Result<(), DomainError> {
|
|
let favicon_key = favicon_key.into();
|
|
let favicon_key = favicon_key.trim();
|
|
if favicon_key.is_empty() {
|
|
return Err(DomainError::EmptyField { field: "favicon_key" });
|
|
}
|
|
|
|
self.favicon_key = Some(favicon_key.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clear_favicon_key(&mut self) {
|
|
self.favicon_key = None;
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn parent_tab_id(&self) -> Option<&TabId> {
|
|
self.parent_tab_id.as_ref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn display_url(&self) -> String {
|
|
self.url.display_url()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn state(&self) -> &TabState {
|
|
&self.state
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn created_at(&self) -> SystemTime {
|
|
self.created_at
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn last_active_at(&self) -> SystemTime {
|
|
self.last_active_at
|
|
}
|
|
|
|
pub fn record_activity(&mut self, active_at: SystemTime) {
|
|
self.last_active_at = active_at;
|
|
}
|
|
|
|
pub fn mark_archived(&mut self) {
|
|
self.state = TabState::Archived;
|
|
}
|
|
|
|
pub fn mark_ready(&mut self) {
|
|
self.state = TabState::Ready;
|
|
}
|
|
|
|
pub fn mark_crashed(&mut self) {
|
|
self.state = TabState::Crashed;
|
|
}
|
|
|
|
pub fn mark_discarded(&mut self) {
|
|
self.state = TabState::Discarded;
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn flags(&self) -> &TabFlags {
|
|
&self.flags
|
|
}
|
|
|
|
pub fn set_favorite(&mut self, favorite: bool) {
|
|
self.flags.favorite = favorite;
|
|
}
|
|
|
|
pub fn set_pinned(&mut self, pinned: bool) {
|
|
self.flags.pinned = pinned;
|
|
}
|
|
|
|
pub fn move_to_space(&mut self, space_id: SpaceId) {
|
|
self.space_id = space_id;
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn group_id(&self) -> Option<&TabGroupId> {
|
|
self.group_id.as_ref()
|
|
}
|
|
|
|
pub fn set_group_id(&mut self, group_id: TabGroupId) {
|
|
self.group_id = Some(group_id);
|
|
}
|
|
|
|
pub fn clear_group_id(&mut self) {
|
|
self.group_id = None;
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn split_id(&self) -> Option<&SplitId> {
|
|
self.split_id.as_ref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sync_enabled(&self) -> bool {
|
|
self.sync_enabled
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn zoom_percent(&self) -> u16 {
|
|
self.zoom_percent
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn zoom_factor(&self) -> f32 {
|
|
f32::from(self.zoom_percent) / f32::from(DEFAULT_ZOOM_PERCENT)
|
|
}
|
|
|
|
pub fn set_zoom_percent(&mut self, zoom_percent: u16) -> Result<(), DomainError> {
|
|
self.zoom_percent = validate_zoom_percent(zoom_percent)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn zoom_in(&mut self) {
|
|
self.zoom_percent =
|
|
self.zoom_percent.saturating_add(ZOOM_PERCENT_STEP).min(MAX_ZOOM_PERCENT);
|
|
}
|
|
|
|
pub fn zoom_out(&mut self) {
|
|
self.zoom_percent =
|
|
self.zoom_percent.saturating_sub(ZOOM_PERCENT_STEP).max(MIN_ZOOM_PERCENT);
|
|
}
|
|
|
|
pub fn reset_zoom(&mut self) {
|
|
self.zoom_percent = DEFAULT_ZOOM_PERCENT;
|
|
}
|
|
|
|
pub fn set_split_id(&mut self, split_id: SplitId) {
|
|
self.split_id = Some(split_id);
|
|
}
|
|
|
|
pub fn clear_split_id(&mut self) {
|
|
self.split_id = None;
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sort_key(&self) -> u64 {
|
|
self.sort_key
|
|
}
|
|
|
|
pub fn set_sort_key(&mut self, sort_key: u64) {
|
|
self.sort_key = sort_key;
|
|
}
|
|
|
|
pub fn set_sync_enabled(&mut self, sync_enabled: bool) {
|
|
self.sync_enabled = sync_enabled;
|
|
}
|
|
|
|
/// Replace this tab's URL in place. Used for in-tab navigation
|
|
/// (clicking a link, picking a settings sub-page, etc.) where the
|
|
/// active tab should follow the user instead of spawning a new
|
|
/// 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) {
|
|
self.url = url;
|
|
self.last_active_at = SystemTime::now();
|
|
}
|
|
}
|
|
|
|
pub fn validate_zoom_percent(value: u16) -> Result<u16, DomainError> {
|
|
if (MIN_ZOOM_PERCENT..=MAX_ZOOM_PERCENT).contains(&value) {
|
|
return Ok(value);
|
|
}
|
|
|
|
Err(DomainError::InvalidZoomPercent { value, min: MIN_ZOOM_PERCENT, max: MAX_ZOOM_PERCENT })
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn parse_title_unread_count(title: &str) -> u32 {
|
|
let trimmed = title.trim_start();
|
|
let Some(rest) = trimmed.strip_prefix('(') else {
|
|
return 0;
|
|
};
|
|
let Some(close) = rest.find(')') else {
|
|
return 0;
|
|
};
|
|
rest[..close].parse::<u32>().unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::parse_title_unread_count;
|
|
|
|
#[test]
|
|
fn parses_leading_parenthesised_count() {
|
|
assert_eq!(parse_title_unread_count("(12) Slack"), 12);
|
|
assert_eq!(parse_title_unread_count("(3) Inbox — Linear"), 3);
|
|
assert_eq!(parse_title_unread_count("(0) Quiet"), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_non_prefixed_titles() {
|
|
assert_eq!(parse_title_unread_count("Slack"), 0);
|
|
assert_eq!(parse_title_unread_count("Inbox (12)"), 0);
|
|
assert_eq!(parse_title_unread_count(""), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_numeric_or_overflow_counts() {
|
|
assert_eq!(parse_title_unread_count("(99+) Gmail"), 0);
|
|
assert_eq!(parse_title_unread_count("(abc) Mail"), 0);
|
|
assert_eq!(parse_title_unread_count("(99999999999999999999) huge"), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn tolerates_leading_whitespace() {
|
|
assert_eq!(parse_title_unread_count(" (7) Slack"), 7);
|
|
}
|
|
}
|