diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 018e59a..d7eb716 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -11,7 +11,6 @@ use std::{ time::Duration, }; -use ely_design_system::spacing; use ely_domain::UrlText; use gpui::{ AnyWindowHandle, App, AppContext, Application, Bounds, Entity, Focusable, Menu, MenuItem, @@ -19,6 +18,7 @@ use gpui::{ }; use gpui_component_assets::Assets; use shell::ElyShell; +use shell::chrome::{TRAFFIC_LIGHT_ORIGIN_X, TRAFFIC_LIGHT_ORIGIN_Y}; use shortcuts::bind_shortcuts; use crate::brand::{DEEP_LINK_PREFIX, PRODUCT_NAME}; @@ -52,11 +52,6 @@ actions!( ] ); -// Measured from the window's top-left to the close button origin. -// Places the macOS traffic lights inside the calm part of the corner curve. -const TRAFFIC_LIGHT_ORIGIN_X: f32 = spacing::SHELL_INSET + 34.0; -const TRAFFIC_LIGHT_ORIGIN_Y: f32 = spacing::SHELL_INSET + 22.0; - fn main() { init_tracing(); let pending_deep_links = PendingDeepLinks::default(); diff --git a/crates/ely_app/src/services/servo_profile_data.rs b/crates/ely_app/src/services/servo_profile_data.rs index 4f297f6..575b101 100644 --- a/crates/ely_app/src/services/servo_profile_data.rs +++ b/crates/ely_app/src/services/servo_profile_data.rs @@ -5,11 +5,12 @@ use std::{ }; use directories::ProjectDirs; -use ely_domain::ProfileId; +use ely_domain::{ProfileId, ProfileKind}; const ELY_QUALIFIER: &str = "com"; const ELY_ORGANIZATION: &str = "elydora"; const ELY_APPLICATION: &str = "ELY Browser"; +const DEFAULT_STANDARD_PROFILE_DIR: &str = "default"; pub(crate) fn default_profile_data_root() -> Option { ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION) @@ -20,6 +21,18 @@ pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) profile_data_root.join(profile_id.as_str()).join("servo") } +pub(crate) fn sync_profile_data_dir( + profile_data_root: &Path, + profile_id: &ProfileId, + profile_name: &str, + profile_kind: &ProfileKind, +) -> PathBuf { + if profile_name == "Default" && matches!(profile_kind, ProfileKind::Standard) { + return profile_data_root.join(DEFAULT_STANDARD_PROFILE_DIR).join("servo"); + } + profile_data_dir(profile_data_root, profile_id) +} + pub(crate) fn transient_profile_data_dir( profile_id: &ProfileId, ) -> Result { @@ -36,3 +49,32 @@ pub(crate) enum ProfileDataMode { Persistent, Transient, } + +#[cfg(test)] +mod tests { + use super::{profile_data_dir, sync_profile_data_dir}; + use ely_domain::{ProfileId, ProfileKind}; + + #[test] + fn default_standard_sync_profile_dir_is_stable() { + let root = std::path::Path::new("/profiles"); + let first_id = ProfileId::new(); + let second_id = ProfileId::new(); + + assert_eq!( + sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard), + sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard) + ); + } + + #[test] + fn custom_sync_profile_dir_keeps_profile_identity() { + let root = std::path::Path::new("/profiles"); + let profile_id = ProfileId::new(); + + assert_eq!( + sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard), + profile_data_dir(root, &profile_id) + ); + } +} diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs index 66538f2..f30cbb8 100644 --- a/crates/ely_app/src/shell/auth.rs +++ b/crates/ely_app/src/shell/auth.rs @@ -10,10 +10,11 @@ use std::sync::mpsc::Sender; use ely_browser_core::SyncEngine; +use ely_domain::{ProfileId, ProfileKind}; use ely_sync_client::{ApiClientConfig, BearerToken, send_email_otp, verify_email_otp}; use gpui::Context; -use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; +use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; use super::sync_state::{SyncStateUpdate, sync_platform_label}; use super::{ElyShell, ShellState}; @@ -44,16 +45,6 @@ pub(crate) enum AuthFlowPhase { } impl AuthFlowPhase { - pub(crate) fn email(&self) -> Option<&str> { - match self { - Self::Idle => None, - Self::SendingCode { email } - | Self::AwaitingOtp { email } - | Self::Verifying { email } - | Self::Error { email, .. } => Some(email), - } - } - pub(crate) fn error_message(&self) -> Option<&str> { match self { Self::Error { message, .. } => Some(message.as_str()), @@ -102,8 +93,8 @@ impl ElyShell { AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() }; return; } - let active_profile_id = match active_profile_id_for(&self.state) { - Some(id) => id, + let active_profile = match active_profile_sync_context_for(&self.state) { + Some(profile) => profile, None => return, }; let Some(profile_root) = default_profile_data_root() else { @@ -113,7 +104,12 @@ impl ElyShell { }; return; }; - let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + let profile_dir = sync_profile_data_dir( + &profile_root, + &active_profile.id, + &active_profile.name, + &active_profile.kind, + ); self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() }; let tx = self.sync_inbox_tx.clone(); spawn_verify_otp(email, normalized_otp, profile_dir, tx); @@ -124,14 +120,19 @@ impl ElyShell { /// call to make, the token is the only artefact we own. pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context) { self.auth_flow_phase = AuthFlowPhase::Idle; - let active_profile_id = match active_profile_id_for(&self.state) { - Some(id) => id, + let active_profile = match active_profile_sync_context_for(&self.state) { + Some(profile) => profile, None => return, }; let Some(profile_root) = default_profile_data_root() else { return; }; - let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + let profile_dir = sync_profile_data_dir( + &profile_root, + &active_profile.id, + &active_profile.name, + &active_profile.kind, + ); match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) { Ok(mut engine) => { let _ = engine.install_bearer(""); @@ -162,11 +163,22 @@ fn normalize_email(raw: &str) -> Option { Some(trimmed.to_lowercase()) } -fn active_profile_id_for(state: &ShellState) -> Option { +#[derive(Clone, Debug, Eq, PartialEq)] +struct ActiveProfileSyncContext { + id: ProfileId, + name: String, + kind: ProfileKind, +} + +fn active_profile_sync_context_for(state: &ShellState) -> Option { let ShellState::Ready(core) = state else { return None; }; - core.snapshot().ok().map(|snapshot| snapshot.active_profile_id.clone()) + core.snapshot().ok().map(|snapshot| ActiveProfileSyncContext { + id: snapshot.active_profile_id, + name: snapshot.active_profile_name, + kind: snapshot.active_profile_kind, + }) } fn spawn_send_otp(email: String, tx: Sender) { @@ -253,7 +265,6 @@ mod tests { #[test] fn auth_phase_helpers() { let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() }; - assert_eq!(phase.email(), Some("you@there")); assert!(phase.is_busy()); assert_eq!(phase.error_message(), None); diff --git a/crates/ely_app/src/shell/chrome/mod.rs b/crates/ely_app/src/shell/chrome/mod.rs index e1ff351..a9b4bc3 100644 --- a/crates/ely_app/src/shell/chrome/mod.rs +++ b/crates/ely_app/src/shell/chrome/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod split_pane; pub(crate) mod topbar; pub(crate) mod typography; pub(crate) mod wallpaper; +pub(crate) mod window_traffic_lights; pub(crate) use appearance_form::render_appearance_form; pub(crate) use brand_glyph::{accent_color_for_host, render_glyph_for}; @@ -34,3 +35,6 @@ pub(crate) use split_pane::{ pub(crate) use topbar::render_topbar; pub(crate) use typography::{SANS_FAMILY, SERIF_FAMILY, register_serif_fonts}; pub(crate) use wallpaper::render_wallpaper; +pub(crate) use window_traffic_lights::{ + TRAFFIC_LIGHT_ORIGIN_X, TRAFFIC_LIGHT_ORIGIN_Y, render_macos_traffic_light_hitboxes, +}; diff --git a/crates/ely_app/src/shell/chrome/settings_layout.rs b/crates/ely_app/src/shell/chrome/settings_layout.rs index ec2ee95..6b0c852 100644 --- a/crates/ely_app/src/shell/chrome/settings_layout.rs +++ b/crates/ely_app/src/shell/chrome/settings_layout.rs @@ -100,7 +100,7 @@ const NAV_GROUPS: &[NavGroup] = &[ /// right swapped" instead of "the layout disappeared and a new tab /// opened" — the prior behavior that misread to users as a tab spawn. pub(crate) fn render_settings_shell( - snapshot: &BrowserSnapshot, + _snapshot: &BrowserSnapshot, active_route: &str, content: AnyElement, cx: &mut Context, @@ -109,16 +109,12 @@ pub(crate) fn render_settings_shell( .flex_1() .h_full() .flex() - .child(render_nav_column(snapshot, active_route, cx)) + .child(render_nav_column(active_route, cx)) .child(content) .into_any_element() } -fn render_nav_column( - snapshot: &BrowserSnapshot, - active_route: &str, - cx: &mut Context, -) -> AnyElement { +fn render_nav_column(active_route: &str, cx: &mut Context) -> AnyElement { div() .w(px(232.0)) .h_full() @@ -132,7 +128,7 @@ fn render_nav_column( .flex_col() .gap(px(2.0)) .overflow_y_scrollbar() - .child(render_nav_brand(snapshot)) + .child(render_nav_brand()) .children( NAV_GROUPS .iter() @@ -142,13 +138,10 @@ fn render_nav_column( .into_any_element() } -fn render_nav_brand(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_nav_brand() -> AnyElement { div() .px(px(12.0)) .pb(px(12.0)) - .flex() - .flex_col() - .gap_1() .child( div() .text_size(px(18.0)) @@ -156,12 +149,6 @@ fn render_nav_brand(snapshot: &BrowserSnapshot) -> AnyElement { .text_color(rgb(colors::ink())) .child("Settings"), ) - .child( - div() - .text_size(px(11.5)) - .text_color(rgb(colors::ink_4())) - .child(format!("ELY 0.42 · Profile: {}", snapshot.active_profile_name)), - ) .into_any_element() } diff --git a/crates/ely_app/src/shell/chrome/sidebar.rs b/crates/ely_app/src/shell/chrome/sidebar.rs index df0744f..ab39be1 100644 --- a/crates/ely_app/src/shell/chrome/sidebar.rs +++ b/crates/ely_app/src/shell/chrome/sidebar.rs @@ -2,9 +2,9 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; use ely_domain::BrowserTab; use gpui::{ - AnyElement, Context, FontWeight, ImageSource, InteractiveElement, IntoElement, ObjectFit, - ParentElement, SharedString, StatefulInteractiveElement, Styled, StyledImage, div, hsla, img, - linear_color_stop, linear_gradient, prelude::FluentBuilder, px, rgb, rgba, + AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, + prelude::FluentBuilder, px, rgb, rgba, }; use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; @@ -464,30 +464,12 @@ where chrome_motion_feedback(press_id, selection_id, false, element) } -/// Resolve the favicon glyph for a tab row. Prefers the favicon URL -/// the Servo runtime derived from the loaded URL; falls back to the -/// initial-letter chip used everywhere else when the tab has no live -/// favicon (yet to load, internal page, file URL, etc.). +/// Resolve the favicon glyph for a tab row. Favicon metadata stays as +/// a local key; rows render the host-derived glyph so the sidebar never +/// blocks on network image assets. fn render_tab_favicon(tab: &BrowserTab, initial: &str) -> AnyElement { - if let Some(favicon_url) = tab.favicon_key() - && favicon_url.starts_with("http") - { - return div() - .size(px(FAVICON_SIZE)) - .flex_shrink_0() - .rounded(px(FAVICON_RADIUS)) - .overflow_hidden() - .child( - img(ImageSource::from(favicon_url.to_string())) - .size(px(FAVICON_SIZE)) - .object_fit(ObjectFit::Cover), - ) - .into_any_element(); - } - let host = tab.url().host(); render_glyph_for(host.as_deref(), initial, FAVICON_SIZE) } const FAVICON_SIZE: f32 = 16.0; -const FAVICON_RADIUS: f32 = 4.0; diff --git a/crates/ely_app/src/shell/chrome/sidebar_header.rs b/crates/ely_app/src/shell/chrome/sidebar_header.rs index fbf0437..3e363b9 100644 --- a/crates/ely_app/src/shell/chrome/sidebar_header.rs +++ b/crates/ely_app/src/shell/chrome/sidebar_header.rs @@ -197,6 +197,12 @@ pub(crate) fn render_workspace_disclosure( .border_1() .border_color(rgba(disclosure_border())) .shadow(soft_shadow()) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _: &gpui::MouseDownEvent, _, cx| { + cx.stop_propagation(); + }), + ) .children( snapshot .spaces @@ -333,8 +339,7 @@ fn render_new_workspace_row(cx: &mut Context) -> AnyElement { .hover(|style| style.bg(rgba(disclosure_row_hover_bg())).text_color(rgb(colors::ink()))) .active(|style| style.opacity(0.85)) .on_click(cx.listener(|shell, _, window, cx| { - shell.close_workspace_picker(cx); - shell.open_internal_tab("ely://settings/spaces", window, cx); + shell.create_workspace_from_picker(window, cx); })) .child(div().text_color(rgb(colors::ink_3())).child(IconName::Plus)) .child(div().flex_1().min_w_0().truncate().child("New workspace")) diff --git a/crates/ely_app/src/shell/chrome/window_traffic_lights.rs b/crates/ely_app/src/shell/chrome/window_traffic_lights.rs new file mode 100644 index 0000000..d497eaf --- /dev/null +++ b/crates/ely_app/src/shell/chrome/window_traffic_lights.rs @@ -0,0 +1,84 @@ +use ely_design_system::spacing; +use gpui::{AnyElement, Context, IntoElement, div}; +#[cfg(target_os = "macos")] +use gpui::{ + App, InteractiveElement, MouseButton, ParentElement, SharedString, StatefulInteractiveElement, + Styled, Window, px, +}; + +use crate::shell::ElyShell; + +// Measured from the window's top-left to the close button origin. +// Places the macOS traffic lights inside the calm part of the corner curve. +pub(crate) const TRAFFIC_LIGHT_ORIGIN_X: f32 = spacing::SHELL_INSET + 34.0; +pub(crate) const TRAFFIC_LIGHT_ORIGIN_Y: f32 = spacing::SHELL_INSET + 22.0; + +#[cfg(target_os = "macos")] +const TRAFFIC_LIGHT_HITBOX_SIZE: f32 = 18.0; +#[cfg(target_os = "macos")] +const TRAFFIC_LIGHT_HITBOX_OFFSET: f32 = -2.0; +#[cfg(target_os = "macos")] +const TRAFFIC_LIGHT_BUTTON_SPACING: f32 = 20.0; + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy)] +enum TrafficLightAction { + Close, + Minimize, + Zoom, +} + +#[cfg(target_os = "macos")] +pub(crate) fn render_macos_traffic_light_hitboxes(_cx: &mut Context) -> AnyElement { + div() + .absolute() + .top_0() + .left_0() + .child(render_traffic_light_hitbox("macos-close-hitbox", 0.0, TrafficLightAction::Close)) + .child(render_traffic_light_hitbox( + "macos-minimize-hitbox", + 1.0, + TrafficLightAction::Minimize, + )) + .child(render_traffic_light_hitbox("macos-zoom-hitbox", 2.0, TrafficLightAction::Zoom)) + .into_any_element() +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn render_macos_traffic_light_hitboxes(_cx: &mut Context) -> AnyElement { + div().into_any_element() +} + +#[cfg(target_os = "macos")] +fn render_traffic_light_hitbox( + id: &'static str, + index: f32, + action: TrafficLightAction, +) -> AnyElement { + div() + .id(SharedString::from(id)) + .absolute() + .left(px(TRAFFIC_LIGHT_ORIGIN_X + + index * TRAFFIC_LIGHT_BUTTON_SPACING + + TRAFFIC_LIGHT_HITBOX_OFFSET)) + .top(px(TRAFFIC_LIGHT_ORIGIN_Y + TRAFFIC_LIGHT_HITBOX_OFFSET)) + .size(px(TRAFFIC_LIGHT_HITBOX_SIZE)) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_click(move |_, window, cx| { + run_traffic_light_action(action, window, cx); + cx.stop_propagation(); + }) + .into_any_element() +} + +#[cfg(target_os = "macos")] +fn run_traffic_light_action(action: TrafficLightAction, window: &mut Window, _cx: &mut App) { + match action { + TrafficLightAction::Close => window.remove_window(), + TrafficLightAction::Minimize => window.minimize_window(), + TrafficLightAction::Zoom => window.zoom_window(), + } +} diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 41668e9..fa935ac 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -17,7 +17,6 @@ mod plugin_catalog; mod plugin_details; mod plugin_editors_pick; mod plugins; -mod privacy_data_inventory; mod privacy_security; mod profiles; mod reading_list; diff --git a/crates/ely_app/src/shell/internal_pages/advanced.rs b/crates/ely_app/src/shell/internal_pages/advanced.rs index 73aebb1..b1cac11 100644 --- a/crates/ely_app/src/shell/internal_pages/advanced.rs +++ b/crates/ely_app/src/shell/internal_pages/advanced.rs @@ -15,89 +15,19 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_advanced_header(snapshot)) - .child(render_advanced_summary(snapshot)) + .child(render_advanced_header()) .child(render_advanced_rows(snapshot)), ) } } -fn render_advanced_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_advanced_header() -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Advanced")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Space: {}", snapshot.active_space_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Inspector) - .child(format!("{} policies", advanced_policy_count())), - ) - .into_any_element() -} - -fn render_advanced_summary(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::primary())).child(IconName::Inspector)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child("Local Runtime"), - ) - .child(div().text_xs().truncate().text_color(rgb(colors::muted())).child( - format!( - "{} space / {} profile", - snapshot.active_space_name, snapshot.active_profile_name - ), - )), - ), - ) - .child(div().text_xs().font_semibold().text_color(rgb(colors::success())).child("Local")) + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Advanced")) .into_any_element() } @@ -132,24 +62,6 @@ fn render_advanced_rows(snapshot: &BrowserSnapshot) -> AnyElement { download_policy_label(&snapshot.active_download_policy), "Active Profile download destination policy", )) - .child(advanced_row( - IconName::Globe, - "Sync Objects", - snapshot.sync_status.objects().len().to_string(), - "Object scopes tracked by local Sync state", - )) - .child(advanced_row( - IconName::Asterisk, - "Installed Plugins", - snapshot.installed_plugins.len().to_string(), - "Verified plugins registered in Browser Core", - )) - .child(advanced_row( - IconName::Inspector, - "Audit Events", - audit_event_count(snapshot).to_string(), - "Plugin and site permission audit records", - )) .into_any_element() } @@ -236,11 +148,3 @@ fn archive_policy_label(policy: &ArchivePolicy) -> &'static str { ArchivePolicy::IdleDays(_) => "Custom", } } - -fn audit_event_count(snapshot: &BrowserSnapshot) -> usize { - snapshot.plugin_audit_events.len() + snapshot.site_permission_audit_events.len() -} - -fn advanced_policy_count() -> usize { - 8 -} diff --git a/crates/ely_app/src/shell/internal_pages/download_settings.rs b/crates/ely_app/src/shell/internal_pages/download_settings.rs index 071703e..6928ef4 100644 --- a/crates/ely_app/src/shell/internal_pages/download_settings.rs +++ b/crates/ely_app/src/shell/internal_pages/download_settings.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use directories::UserDirs; use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{DownloadDestination, DownloadPolicy}; +use ely_domain::DownloadPolicy; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ IconName, Selectable, Sizable, StyledExt, @@ -11,7 +11,7 @@ use gpui_component::{ scroll::ScrollableElement, }; -use super::{ElyShell, download_labels::download_policy_label, render_canvas_surface}; +use super::{ElyShell, render_canvas_surface}; #[derive(Clone)] struct DownloadPolicyOption { @@ -36,115 +36,29 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_download_settings_header(snapshot)) - .child(render_download_policy_summary(snapshot, cx)) + .child(render_download_settings_header(cx)) .child(render_download_policy_rows(&snapshot.active_download_policy, &options, cx)), ) } } -fn render_download_settings_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_download_settings_header(cx: &mut Context) -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Downloads")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Folder) - .child(download_destination_short_label(&snapshot.active_download_policy)), - ) - .into_any_element() -} - -fn render_download_policy_summary( - snapshot: &BrowserSnapshot, - cx: &mut Context, -) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Downloads")) .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::primary())).child(IconName::Folder)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child("Download location"), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(download_policy_label(&snapshot.active_download_policy)), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(format!("{} entries", snapshot.download_entries.len())), - ) - .child( - Button::new("reset-download-settings") - .ghost() - .xsmall() - .icon(IconName::Undo2) - .label("Reset") - .tooltip("Restore Download Defaults") - .on_click(cx.listener(|shell, _, _, cx| { - shell.reset_active_profile_download_settings(cx); - })), - ), + Button::new("reset-download-settings") + .ghost() + .xsmall() + .icon(IconName::Undo2) + .label("Reset") + .tooltip("Restore Download Defaults") + .on_click(cx.listener(|shell, _, _, cx| { + shell.reset_active_profile_download_settings(cx); + })), ) .into_any_element() } @@ -261,13 +175,6 @@ fn user_downloads_dir() -> Option { UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) } -fn download_destination_short_label(policy: &DownloadPolicy) -> &'static str { - match policy.destination() { - DownloadDestination::AskEveryTime => "Ask Every Time", - DownloadDestination::FixedDirectory(_) => "Fixed Folder", - } -} - fn download_policy_icon(option: &DownloadPolicyOption, selected: bool) -> IconName { if selected { IconName::CircleCheck } else { option.icon.clone() } } diff --git a/crates/ely_app/src/shell/internal_pages/general.rs b/crates/ely_app/src/shell/internal_pages/general.rs index aaf18f6..2461b7e 100644 --- a/crates/ely_app/src/shell/internal_pages/general.rs +++ b/crates/ely_app/src/shell/internal_pages/general.rs @@ -23,117 +23,29 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_general_header(snapshot)) - .child(render_general_summary(snapshot.new_tab_destination, cx)) + .child(render_general_header(cx)) .child(render_new_tab_destinations(snapshot.new_tab_destination, cx)), ) } } -fn render_general_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_general_header(cx: &mut Context) -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("General")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Settings2) - .child(snapshot.new_tab_destination.name()), - ) - .into_any_element() -} - -fn render_general_summary( - destination: NewTabDestination, - cx: &mut Context, -) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("General")) .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child( - div().text_color(rgb(colors::primary())).child(destination_icon(destination)), - ) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(format!("New Tab opens {}", destination.name())), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(destination.detail()), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(rgb(colors::success())) - .child("Saved locally"), - ) - .child( - Button::new("reset-general-settings") - .ghost() - .xsmall() - .icon(IconName::Undo2) - .label("Reset") - .tooltip("Restore General Defaults") - .on_click(cx.listener(|shell, _, _, cx| { - shell.reset_general_settings(cx); - })), - ), + Button::new("reset-general-settings") + .ghost() + .xsmall() + .icon(IconName::Undo2) + .label("Reset") + .tooltip("Restore General Defaults") + .on_click(cx.listener(|shell, _, _, cx| { + shell.reset_general_settings(cx); + })), ) .into_any_element() } diff --git a/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs b/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs deleted file mode 100644 index 59a9d11..0000000 --- a/crates/ely_app/src/shell/internal_pages/privacy_data_inventory.rs +++ /dev/null @@ -1,183 +0,0 @@ -use ely_browser_core::{BrowserSnapshot, LocalDataInventory}; -use ely_design_system::colors; -use gpui::prelude::FluentBuilder; -use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, rgb}; -use gpui_component::{ - IconName, Sizable, StyledExt, - button::{Button, ButtonVariants}, -}; - -use super::ElyShell; - -pub(super) fn render_local_data_inventory( - snapshot: &BrowserSnapshot, - notice: Option<&str>, - error: Option<&str>, - cx: &mut Context, -) -> AnyElement { - let inventory = snapshot.local_data_inventory; - - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() - .flex() - .flex_col() - .gap_3() - .child(render_inventory_header(snapshot, inventory, cx)) - .when_some(file_message(notice, error), |this, message| this.child(message)) - .child(render_inventory_rows(inventory)) - .into_any_element() -} - -fn render_inventory_header( - snapshot: &BrowserSnapshot, - inventory: LocalDataInventory, - cx: &mut Context, -) -> AnyElement { - div() - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex_1() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::primary())).child(IconName::Inspector)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child("Local Data"), - ) - .child(div().text_xs().truncate().text_color(rgb(colors::muted())).child( - format!( - "{} Profile inventory for review, export, and deletion.", - snapshot.active_profile_name - ), - )), - ), - ) - .child( - div() - .flex_none() - .flex() - .items_center() - .gap_2() - .child( - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas())) - .px_3() - .py_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(format!("{} items", inventory.total_items())), - ) - .child( - Button::new("export-local-data") - .ghost() - .xsmall() - .icon(IconName::File) - .label("Export") - .tooltip("Export Local Data") - .on_click(cx.listener(|shell, _, window, cx| { - shell.export_local_data(window, cx); - })), - ), - ) - .into_any_element() -} - -fn file_message(notice: Option<&str>, error: Option<&str>) -> Option { - notice - .map(|message| render_file_message(message, colors::success())) - .or_else(|| error.map(|message| render_file_message(message, colors::error()))) -} - -fn render_file_message(message: &str, color: u32) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(color)) - .px_3() - .py_2() - .text_xs() - .font_semibold() - .text_color(rgb(color)) - .child(message.to_string()) - .into_any_element() -} - -fn render_inventory_rows(inventory: LocalDataInventory) -> AnyElement { - div() - .flex() - .flex_col() - .child(inventory_row(IconName::Globe, "Open Tabs", inventory.open_tabs())) - .child(inventory_row(IconName::Inbox, "Archived Tabs", inventory.archived_tabs())) - .child(inventory_row(IconName::Undo2, "History", inventory.history_entries())) - .child(inventory_row(IconName::BookOpen, "Bookmarks", inventory.bookmarks())) - .child(inventory_row(IconName::File, "Notes", inventory.notes())) - .child(inventory_row(IconName::CircleCheck, "Reading List", inventory.reading_list())) - .child(inventory_row(IconName::Folder, "Downloads", inventory.downloads())) - .child(inventory_row(IconName::Eye, "Site Permissions", inventory.site_permissions())) - .child(inventory_row( - IconName::Inspector, - "Audit Events", - inventory.site_permission_audit_events(), - )) - .into_any_element() -} - -fn inventory_row(icon: IconName, label: &'static str, count: usize) -> AnyElement { - div() - .py_2() - .border_t_1() - .border_color(rgb(colors::hairline())) - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::muted_soft())).child(icon)) - .child( - div() - .min_w_0() - .truncate() - .text_sm() - .text_color(rgb(colors::ink())) - .child(label), - ), - ) - .child( - div() - .flex_none() - .text_sm() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(count.to_string()), - ) - .into_any_element() -} diff --git a/crates/ely_app/src/shell/internal_pages/privacy_security.rs b/crates/ely_app/src/shell/internal_pages/privacy_security.rs index eeaa36f..30d5df8 100644 --- a/crates/ely_app/src/shell/internal_pages/privacy_security.rs +++ b/crates/ely_app/src/shell/internal_pages/privacy_security.rs @@ -9,7 +9,7 @@ use gpui_component::{ scroll::ScrollableElement, }; -use super::{ElyShell, privacy_data_inventory::render_local_data_inventory, render_canvas_surface}; +use super::{ElyShell, render_canvas_surface}; impl ElyShell { pub(super) fn render_privacy_security_page( @@ -27,126 +27,32 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_privacy_header(snapshot)) - .child(render_history_summary(snapshot, cx)) + .child(render_privacy_header(cx)) .when(snapshot.active_profile_history_entry_count > 0, |this| { this.child(render_history_clear_controls(confirming_clear, cx)) }) - .child(render_local_data_inventory( - snapshot, - self.local_data_file_notice.as_deref(), - self.local_data_file_error.as_deref(), - cx, - )) .child(render_privacy_settings_rows(snapshot, cx)), ) } } -fn render_privacy_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_privacy_header(cx: &mut Context) -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child( - div() - .text_size(px(26.0)) - .text_color(rgb(colors::ink())) - .child("Privacy & Security"), - ) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(privacy_icon(snapshot.history_recording_policy)) - .child(snapshot.history_recording_policy.status()), - ) - .into_any_element() -} - -fn render_history_summary(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Privacy & Security")) .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child( - div() - .text_color(rgb(policy_color(snapshot.history_recording_policy))) - .child(privacy_icon(snapshot.history_recording_policy)), - ) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(snapshot.history_recording_policy.name()), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(snapshot.history_recording_policy.detail()), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(div().text_xs().font_semibold().text_color(rgb(colors::muted())).child( - format!("{} Profile entries", snapshot.active_profile_history_entry_count), - )) - .child( - Button::new("reset-privacy-settings") - .ghost() - .xsmall() - .icon(IconName::Undo2) - .label("Reset") - .tooltip("Restore Privacy Defaults") - .on_click(cx.listener(|shell, _, _, cx| { - shell.reset_privacy_settings(cx); - })), - ), + Button::new("reset-privacy-settings") + .ghost() + .xsmall() + .icon(IconName::Undo2) + .label("Reset") + .tooltip("Restore Privacy Defaults") + .on_click(cx.listener(|shell, _, _, cx| { + shell.reset_privacy_settings(cx); + })), ) .into_any_element() } @@ -159,14 +65,7 @@ fn render_history_clear_controls(confirming_clear: bool, cx: &mut Context AnyElement { +fn render_search_header(cx: &mut Context) -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Search")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Search) - .child(snapshot.search_engine.name()), - ) - .into_any_element() -} - -fn render_search_summary(search_engine: SearchEngine, cx: &mut Context) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Search")) .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::primary())).child(IconName::Search)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(search_engine.name()), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(search_engine.host()), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(rgb(colors::success())) - .child("Saved locally"), - ) - .child( - Button::new("reset-search-settings") - .ghost() - .xsmall() - .icon(IconName::Undo2) - .label("Reset") - .tooltip("Restore Search Defaults") - .on_click(cx.listener(|shell, _, _, cx| { - shell.reset_search_settings(cx); - })), - ), + Button::new("reset-search-settings") + .ghost() + .xsmall() + .icon(IconName::Undo2) + .label("Reset") + .tooltip("Restore Search Defaults") + .on_click(cx.listener(|shell, _, _, cx| { + shell.reset_search_settings(cx); + })), ) .into_any_element() } diff --git a/crates/ely_app/src/shell/internal_pages/shortcuts.rs b/crates/ely_app/src/shell/internal_pages/shortcuts.rs index 8989937..2e1da97 100644 --- a/crates/ely_app/src/shell/internal_pages/shortcuts.rs +++ b/crates/ely_app/src/shell/internal_pages/shortcuts.rs @@ -1,5 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; +use gpui::prelude::FluentBuilder; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ IconName, Sizable, StyledExt, @@ -18,7 +19,7 @@ const SHORTCUT_CATEGORIES: &[&str] = &["Command", "Tabs", "Library", "System", " impl ElyShell { pub(super) fn render_shortcuts_page( &mut self, - snapshot: &BrowserSnapshot, + _snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { let conflicts = self.shortcut_profile.conflicts(); @@ -30,64 +31,29 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_shortcuts_header(snapshot, conflicts.len(), cx)) + .child(render_shortcuts_header(cx)) .child(render_shortcut_file_message( self.shortcut_file_notice.as_deref(), self.shortcut_file_error.as_deref(), )) - .child(render_conflict_panel(&conflicts)) + .when(!conflicts.is_empty(), |this| this.child(render_conflict_panel(&conflicts))) .child(render_shortcut_categories(&self.shortcut_profile, &conflicts)), ) } } -fn render_shortcuts_header( - snapshot: &BrowserSnapshot, - conflict_count: usize, - cx: &mut Context, -) -> AnyElement { - let status = if conflict_count == 0 { - "Ready".to_string() - } else { - format!("{conflict_count} conflicts") - }; - +fn render_shortcuts_header(cx: &mut Context) -> AnyElement { div() .flex() - .items_end() + .items_center() .justify_between() .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Shortcuts")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Shortcuts")) .child( div() .flex() .items_center() .gap_2() - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(shortcut_status_color(conflict_count))) - .child(shortcut_status_icon(conflict_count)) - .child(status), - ) .child( Button::new("export-shortcuts") .ghost() @@ -311,7 +277,7 @@ fn render_shortcut_row( .text_xs() .child(shortcut_platform_label(profile, action, ShortcutPlatform::Macos)) .child(shortcut_platform_label(profile, action, ShortcutPlatform::WindowsLinux)) - .child(shortcut_row_status(has_conflict)), + .when(has_conflict, |this| this.child(shortcut_row_status())), ) .into_any_element() } @@ -336,19 +302,13 @@ fn shortcut_platform_label( .into_any_element() } -fn shortcut_row_status(has_conflict: bool) -> AnyElement { - let (label, color) = - if has_conflict { ("Conflict", colors::error()) } else { ("Ready", colors::success()) }; - - div().min_w(px(72.0)).font_semibold().text_color(rgb(color)).child(label).into_any_element() -} - -fn shortcut_status_color(conflict_count: usize) -> u32 { - if conflict_count == 0 { colors::success() } else { colors::error() } -} - -fn shortcut_status_icon(conflict_count: usize) -> IconName { - if conflict_count == 0 { IconName::CircleCheck } else { IconName::TriangleAlert } +fn shortcut_row_status() -> AnyElement { + div() + .min_w(px(72.0)) + .font_semibold() + .text_color(rgb(colors::error())) + .child("Conflict") + .into_any_element() } fn shortcut_row_icon(has_conflict: bool) -> IconName { diff --git a/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs b/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs index 0227aed..ea3bffc 100644 --- a/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs +++ b/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs @@ -23,8 +23,7 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_site_permissions_header(snapshot)) - .child(render_site_permissions_summary(snapshot)) + .child(render_site_permissions_header()) .child(render_site_permissions_controls( snapshot, self.site_permissions_clear_confirmation.as_ref() @@ -36,75 +35,13 @@ impl ElyShell { } } -fn render_site_permissions_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_site_permissions_header() -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child( - div() - .text_size(px(26.0)) - .text_color(rgb(colors::ink())) - .child("Site Permissions"), - ) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Globe) - .child("Profile scoped"), - ) - .into_any_element() -} - -fn render_site_permissions_summary(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .border_t_1() - .border_b_1() - .border_color(rgb(colors::hairline())) - .py_3() .flex() .items_center() .justify_between() .gap_4() - .children([ - site_permission_metric("Configured", snapshot.site_permissions.len()), - site_permission_metric("Allowed", allowed_count(snapshot)), - site_permission_metric("Denied", denied_count(snapshot)), - site_permission_metric("Audit Events", snapshot.site_permission_audit_events.len()), - ]) - .into_any_element() -} - -fn site_permission_metric(label: &'static str, value: usize) -> AnyElement { - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child(div().text_xs().text_color(rgb(colors::muted())).child(label)) - .child( - div().text_sm().font_semibold().text_color(rgb(colors::ink())).child(value.to_string()), - ) + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Site Permissions")) .into_any_element() } @@ -124,14 +61,7 @@ fn render_site_permissions_controls( div() .flex() .items_center() - .justify_between() - .gap_4() - .child( - div() - .text_sm() - .text_color(rgb(colors::muted())) - .child("Clear all configured permissions for this Profile."), - ) + .justify_end() .child( Button::new("request-clear-site-permissions") .danger() @@ -298,27 +228,6 @@ fn render_site_permission_entry( .into_any_element() } -fn allowed_count(snapshot: &BrowserSnapshot) -> usize { - snapshot - .site_permissions - .iter() - .filter(|entry| { - matches!( - entry.decision(), - SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways - ) - }) - .count() -} - -fn denied_count(snapshot: &BrowserSnapshot) -> usize { - snapshot - .site_permissions - .iter() - .filter(|entry| entry.decision() == SitePermissionDecision::DenyAlways) - .count() -} - fn site_settings_route(origin: &SiteOrigin) -> String { format!("ely://site/{}", origin.as_str()) } diff --git a/crates/ely_app/src/shell/internal_pages/site_settings.rs b/crates/ely_app/src/shell/internal_pages/site_settings.rs index a72abe9..3c6ce2d 100644 --- a/crates/ely_app/src/shell/internal_pages/site_settings.rs +++ b/crates/ely_app/src/shell/internal_pages/site_settings.rs @@ -1,9 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{ - SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, - SitePermissionFeature, -}; +use ely_domain::{SiteOrigin, SitePermissionDecision, SitePermissionFeature}; use gpui::prelude::FluentBuilder; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ @@ -40,18 +37,16 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_site_settings_header(snapshot, &origin)) - .child(render_site_permission_summary(snapshot, &origin)) - .child(render_site_permission_rows(snapshot, &origin, cx)) - .child(render_site_permission_audit(snapshot, &origin)), + .child(render_site_settings_header(&origin)) + .child(render_site_permission_rows(snapshot, &origin, cx)), ) } } -fn render_site_settings_header(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { +fn render_site_settings_header(origin: &SiteOrigin) -> AnyElement { div() .flex() - .items_end() + .items_center() .justify_between() .gap_4() .child( @@ -63,54 +58,13 @@ fn render_site_settings_header(snapshot: &BrowserSnapshot, origin: &SiteOrigin) .child( div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Site Settings"), ) - .child(div().text_sm().truncate().text_color(rgb(colors::muted())).child(format!( - "{} / {}", - snapshot.active_profile_name, - origin.as_str() - ))), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::Globe) - .child("Profile scoped"), - ) - .into_any_element() -} - -fn render_site_permission_summary(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { - div() - .border_t_1() - .border_b_1() - .border_color(rgb(colors::hairline())) - .py_3() - .flex() - .items_center() - .justify_between() - .gap_4() - .children([ - site_metric("Configured", configured_count(snapshot, origin)), - site_metric("Allowed", allowed_count(snapshot, origin)), - site_metric("Denied", denied_count(snapshot, origin)), - site_metric("Audit Events", audit_count(snapshot, origin)), - ]) - .into_any_element() -} - -fn site_metric(label: &'static str, value: usize) -> AnyElement { - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child(div().text_xs().text_color(rgb(colors::muted())).child(label)) - .child( - div().text_sm().font_semibold().text_color(rgb(colors::ink())).child(value.to_string()), + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::muted())) + .child(origin.as_str().to_string()), + ), ) .into_any_element() } @@ -280,47 +234,6 @@ fn permission_reset_button( .into_any_element() } -fn render_site_permission_audit(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { - let events = snapshot - .site_permission_audit_events - .iter() - .filter(|event| event.origin() == origin) - .rev() - .take(4) - .collect::>(); - - if events.is_empty() { - return div().into_any_element(); - } - - div() - .flex() - .flex_col() - .gap_2() - .child(div().text_xs().font_semibold().text_color(rgb(colors::muted())).child("Audit")) - .children(events.into_iter().map(render_audit_row)) - .into_any_element() -} - -fn render_audit_row(event: &SitePermissionAuditEvent) -> AnyElement { - div() - .py_2() - .border_b_1() - .border_color(rgb(colors::hairline())) - .flex() - .items_center() - .justify_between() - .gap_3() - .text_xs() - .child(div().min_w_0().truncate().text_color(rgb(colors::body())).child(format!( - "{} - {}", - event.feature().label(), - audit_action_label(event.action()) - ))) - .child(div().text_color(rgb(colors::muted())).child("Local audit")) - .into_any_element() -} - fn render_invalid_site_route() -> AnyElement { div() .size_full() @@ -357,38 +270,6 @@ fn decision_for( .map(|entry| entry.decision()) } -fn configured_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { - snapshot.site_permissions.iter().filter(|entry| entry.origin() == origin).count() -} - -fn allowed_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { - snapshot - .site_permissions - .iter() - .filter(|entry| entry.origin() == origin) - .filter(|entry| { - matches!( - entry.decision(), - SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways - ) - }) - .count() -} - -fn denied_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { - snapshot - .site_permissions - .iter() - .filter(|entry| { - entry.origin() == origin && entry.decision() == SitePermissionDecision::DenyAlways - }) - .count() -} - -fn audit_count(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> usize { - snapshot.site_permission_audit_events.iter().filter(|event| event.origin() == origin).count() -} - fn decision_color(decision: SitePermissionDecision) -> u32 { match decision { SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => { @@ -408,13 +289,6 @@ fn permission_icon(decision: Option) -> IconName { } } -fn audit_action_label(action: &SitePermissionAuditAction) -> &'static str { - match action { - SitePermissionAuditAction::Set(decision) => decision.label(), - SitePermissionAuditAction::Revoked => "Reset", - } -} - fn feature_scope_label(feature: SitePermissionFeature) -> &'static str { match feature { SitePermissionFeature::Camera => "Controls camera capture requests.", diff --git a/crates/ely_app/src/shell/internal_pages/spaces.rs b/crates/ely_app/src/shell/internal_pages/spaces.rs index f0e8deb..c670278 100644 --- a/crates/ely_app/src/shell/internal_pages/spaces.rs +++ b/crates/ely_app/src/shell/internal_pages/spaces.rs @@ -28,39 +28,24 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_spaces_header(snapshot, cx)) + .child(render_spaces_header(cx)) .child(render_space_file_message( self.space_file_notice.as_deref(), self.space_file_error.as_deref(), )) - .child(render_active_space_summary(snapshot)) .child(render_spaces_list(snapshot, self.pending_space_trash.as_ref(), cx)) .child(render_trashed_spaces_list(snapshot, cx)), ) } } -fn render_spaces_header(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { +fn render_spaces_header(cx: &mut Context) -> AnyElement { div() .flex() - .items_end() + .items_center() .justify_between() .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Spaces")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Spaces")) .child( div() .flex() @@ -95,17 +80,6 @@ fn render_spaces_header(snapshot: &BrowserSnapshot, cx: &mut Context) cx, ); })), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::GalleryVerticalEnd) - .child(format!("{} spaces", snapshot.spaces.len())), ), ) .into_any_element() @@ -136,60 +110,6 @@ fn render_space_file_message(notice: Option<&str>, error: Option<&str>) -> AnyEl .into_any_element() } -fn render_active_space_summary(snapshot: &BrowserSnapshot) -> AnyElement { - let Some(active_space) = - snapshot.spaces.iter().find(|space| space.id() == &snapshot.active_space_id) - else { - return div() - .rounded_md() - .border_1() - .border_color(rgb(colors::error())) - .px_4() - .py_3() - .text_sm() - .text_color(rgb(colors::error())) - .child("Active Space is unavailable.") - .into_any_element(); - }; - - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div().min_w_0().flex().items_center().gap_3().child(space_avatar(active_space)).child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(active_space.name().to_string()), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(space_detail_label(active_space, &snapshot.profiles)), - ), - ), - ) - .child(div().text_xs().font_semibold().text_color(rgb(colors::success())).child("Active")) - .into_any_element() -} - fn render_spaces_list( snapshot: &BrowserSnapshot, pending_space_trash: Option<&SpaceId>, diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index bed3209..7adaa16 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -1,21 +1,18 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus}; +use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectStatus}; use gpui::{ AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba, }; -use gpui_component::{IconName, input::Input, scroll::ScrollableElement}; +use gpui_component::{input::Input, scroll::ScrollableElement}; use crate::shell::auth::AuthFlowPhase; -use crate::brand::SYNC_SERVICE_NAME; - use super::sync_controls::{ button_bg, render_dual_button_row, render_policy_toggle, render_primary_button, render_reset_button, render_sign_out_button, }; use super::{ElyShell, render_canvas_surface}; -use crate::shell::chrome::SERIF_FAMILY; impl ElyShell { pub(super) fn render_sync_page( @@ -24,109 +21,41 @@ impl ElyShell { cx: &mut Context, ) -> AnyElement { render_canvas_surface( - div().size_full().pt(px(40.0)).px(px(56.0)).pb(px(32.0)).flex().justify_center().child( - div() - .max_w(px(960.0)) - .grid() - .grid_cols(2) - .gap(px(32.0)) - .child(render_left_column(self, snapshot, cx)) - .child(render_right_column(self, snapshot, cx)), - ), + div() + .size_full() + .p(px(40.0)) + .flex() + .justify_center() + .child(render_sync_body(self, snapshot, cx)), ) } } -fn render_left_column( +fn render_sync_body( shell: &mut ElyShell, snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { div() + .max_w(px(860.0)) .flex() .flex_col() - .items_start() - .gap(px(20.0)) - .child(render_status_pill(snapshot)) - .child(render_serif_headline()) - .child(render_intro_paragraph()) - .child(render_account_card(shell, snapshot, cx)) - .child(render_metrics_card(shell, snapshot, cx)) - .into_any_element() -} - -fn render_status_pill(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .flex() - .items_center() - .gap(px(8.0)) - .px(px(12.0)) - .py(px(5.0)) - .rounded(px(999.0)) - .bg(rgba(pill_bg())) - .text_size(px(11.0)) - .text_color(rgb(colors::ink_3())) - .child(div().text_color(rgb(colors::accent())).child(IconName::Globe)) - .child(format!( - "{SYNC_SERVICE_NAME} · {}", - connection_label(snapshot.sync_status.connection()) - )) - .into_any_element() -} - -fn render_serif_headline() -> AnyElement { - div() - .font_family(SERIF_FAMILY) - .text_size(px(46.0)) - .font_weight(FontWeight(400.0)) - .text_color(rgb(colors::ink())) - .child("Your tabs, on every device.") - .into_any_element() -} - -fn render_intro_paragraph() -> AnyElement { - div() - .max_w(px(440.0)) - .text_size(px(14.0)) - .text_color(rgb(colors::ink_2())) + .gap(px(18.0)) .child( - "ELY keeps tabs, workspaces, pinned items, and history mirrored across your \ - devices — encrypted in your hands and replayed at the edge.", + div() + .text_size(px(26.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink())) + .child("Sync"), ) - .into_any_element() -} - -fn render_metrics_card( - shell: &ElyShell, - snapshot: &BrowserSnapshot, - cx: &mut Context, -) -> AnyElement { - div() - .max_w(px(380.0)) - .p(px(20.0)) - .rounded(px(16.0)) - .bg(rgba(card_bg())) - .flex() - .flex_col() - .gap(px(16.0)) - .child(div().text_size(px(12.5)).text_color(rgb(colors::ink_3())).child("Local queue")) .child( div() .grid() .grid_cols(2) - .gap(px(12.0)) - .child(render_metric( - "Pending", - snapshot.sync_status.pending_objects(), - colors::ink(), - )) - .child(render_metric( - "Failed", - snapshot.sync_status.failed_objects(), - colors::error(), - )), + .gap(px(18.0)) + .child(render_account_card(shell, snapshot, cx)) + .child(render_data_card(shell, snapshot, cx)), ) - .child(render_reset_button(shell, cx)) .into_any_element() } @@ -135,27 +64,19 @@ fn render_account_card( snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { - let card = div() - .max_w(px(380.0)) - .p(px(20.0)) - .rounded(px(16.0)) - .bg(rgba(card_bg())) - .flex() - .flex_col() - .gap(px(14.0)); + let card = + div().p(px(18.0)).rounded(px(12.0)).bg(rgba(card_bg())).flex().flex_col().gap(px(14.0)); match snapshot.sync_status.connection() { SyncConnectionState::SignedOut => card - .child(render_account_heading("Sign in")) - .child(render_account_subtitle("We'll email a 6-digit code from browser@elydora.com.")) + .child(render_card_heading("Account")) .children(account_form(shell, cx)) .into_any_element(), SyncConnectionState::SignedIn | SyncConnectionState::AwaitingDeviceApproval | SyncConnectionState::SyncReady { .. } | SyncConnectionState::SyncError { .. } => card - .child(render_account_heading("Account")) - .child(render_signed_in_chip()) + .child(render_card_heading("Account")) .child(render_sign_out_button(shell, cx)) .into_any_element(), } @@ -163,12 +84,10 @@ fn render_account_card( fn account_form(shell: &ElyShell, cx: &mut Context) -> Vec { let mut elements: Vec = Vec::new(); - let phase = shell.auth_flow_phase.clone(); - let prefill_email = phase.email().map(str::to_string); - elements.push(render_account_label("Email")); - elements.push(render_input(&shell.auth_email_input, prefill_email.as_deref())); + elements.push(render_field_label("Email")); + elements.push(render_input(&shell.auth_email_input)); match &phase { AuthFlowPhase::Idle | AuthFlowPhase::Error { .. } => { @@ -184,18 +103,11 @@ fn account_form(shell: &ElyShell, cx: &mut Context) -> Vec )); } AuthFlowPhase::SendingCode { .. } => { - elements.push(render_primary_button( - shell, - "send-otp", - "Sending...", - true, - cx, - |_, _| {}, - )); + elements.push(render_primary_button(shell, "send-otp", "Sending", true, cx, |_, _| {})); } AuthFlowPhase::AwaitingOtp { .. } | AuthFlowPhase::Verifying { .. } => { - elements.push(render_account_label("Code")); - elements.push(render_input(&shell.auth_otp_input, None)); + elements.push(render_field_label("Code")); + elements.push(render_input(&shell.auth_otp_input)); elements.push(render_dual_button_row( shell, phase.is_busy(), @@ -213,103 +125,14 @@ fn account_form(shell: &ElyShell, cx: &mut Context) -> Vec elements } -fn render_account_heading(label: &str) -> AnyElement { - div() - .text_size(px(13.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::ink())) - .child(label.to_string()) - .into_any_element() -} - -fn render_account_subtitle(text: &str) -> AnyElement { - div() - .text_size(px(12.0)) - .text_color(rgb(colors::ink_3())) - .child(text.to_string()) - .into_any_element() -} - -fn render_account_label(label: &'static str) -> AnyElement { - div() - .text_size(px(10.5)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::ink_4())) - .child(label) - .into_any_element() -} - -fn render_input( - state: &gpui::Entity, - hint: Option<&str>, -) -> AnyElement { - let mut wrapper = div() - .px(px(10.0)) - .py(px(8.0)) - .rounded(px(8.0)) - .bg(rgba(button_bg())) - .child(Input::new(state).appearance(false).cleanable(false)); - if let Some(hint) = hint { - wrapper = wrapper.child( - div().text_size(px(10.0)).text_color(rgb(colors::ink_4())).child(hint.to_string()), - ); - } - wrapper.into_any_element() -} - -fn render_inline_error(message: &str) -> AnyElement { - div() - .text_size(px(11.5)) - .text_color(rgb(colors::error())) - .child(message.to_string()) - .into_any_element() -} - -fn render_signed_in_chip() -> AnyElement { - div() - .text_size(px(13.0)) - .text_color(rgb(colors::ink_2())) - .child("Signed in. New sessions on this device share the same encrypted snapshot.") - .into_any_element() -} - -fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement { - div() - .flex() - .flex_col() - .gap_1() - .child(div().text_size(px(10.5)).text_color(rgb(colors::ink_4())).child(label)) - .child( - div() - .text_size(px(20.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(color)) - .child(value.to_string()), - ) - .into_any_element() -} - -fn render_right_column( - shell: &ElyShell, - snapshot: &BrowserSnapshot, - cx: &mut Context, -) -> AnyElement { - div() - .flex() - .flex_col() - .gap(px(14.0)) - .child(render_what_syncs_card(shell, snapshot, cx)) - .into_any_element() -} - -fn render_what_syncs_card( +fn render_data_card( shell: &ElyShell, snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { div() .p(px(18.0)) - .rounded(px(16.0)) + .rounded(px(12.0)) .bg(rgba(card_bg())) .flex() .flex_col() @@ -320,20 +143,10 @@ fn render_what_syncs_card( div() .flex() .items_center() - .gap(px(8.0)) - .child( - div() - .text_size(px(13.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::ink())) - .child("What syncs"), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(colors::ink_3())) - .child(format!("{} kinds tracked", snapshot.sync_status.objects().len())), - ), + .justify_between() + .gap(px(10.0)) + .child(render_card_heading("Data")) + .child(render_reset_button(shell, cx)), ) .child( div().flex().flex_col().gap(px(2.0)).children( @@ -357,86 +170,58 @@ fn render_sync_object_row( div() .flex() .items_center() - .gap(px(10.0)) - .py(px(8.0)) + .justify_between() + .gap(px(12.0)) + .py(px(9.0)) .border_b_1() .border_color(rgba(colors::divider())) - .child(render_state_dot(status.state())) .child( div() .flex_1() .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_size(px(13.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::ink())) - .child(sync_object_kind_label(status.kind())), - ) - .child(div().text_size(px(11.0)).text_color(rgb(colors::ink_4())).child(format!( - "{} local · {}", - status.local_count(), - sync_object_state_label(status.state()) - ))), + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink())) + .child(sync_object_kind_label(status.kind())), ) .child(render_policy_toggle(shell, index, status, cx)) .into_any_element() } -fn render_state_dot(state: SyncObjectState) -> AnyElement { - let color = match state { - SyncObjectState::LocalOnly => colors::ink_4(), - SyncObjectState::Paused => colors::ink_5(), - SyncObjectState::PrivacyControlled => colors::accent(), - SyncObjectState::Synced => colors::success(), - }; - - div().size(px(8.0)).rounded_full().bg(rgb(color)).into_any_element() +fn render_card_heading(label: &'static str) -> AnyElement { + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink())) + .child(label) + .into_any_element() } -fn connection_label(connection: &SyncConnectionState) -> String { - match connection { - SyncConnectionState::SignedOut => "Local-only · drop a session token to enable".to_string(), - SyncConnectionState::SignedIn => "Signed in · awaiting first sync".to_string(), - SyncConnectionState::AwaitingDeviceApproval => { - "Signed in · waiting for device approval".to_string() - } - SyncConnectionState::SyncReady { last_synced_at_secs } => { - format!("Synced · last upload {}", relative_time_since(*last_synced_at_secs)) - } - SyncConnectionState::SyncError { message } => { - format!("Sync error · {}", short_message(message)) - } - } +fn render_field_label(label: &'static str) -> AnyElement { + div() + .text_size(px(10.5)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink_4())) + .child(label) + .into_any_element() } -fn relative_time_since(secs: u64) -> String { - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - let when = UNIX_EPOCH + Duration::from_secs(secs); - let elapsed = SystemTime::now().duration_since(when).unwrap_or_default(); - let total_secs = elapsed.as_secs(); - if total_secs < 60 { - return format!("{total_secs}s ago"); - } - if total_secs < 3600 { - return format!("{}m ago", total_secs / 60); - } - if total_secs < 86400 { - return format!("{}h ago", total_secs / 3600); - } - format!("{}d ago", total_secs / 86400) +fn render_input(state: &gpui::Entity) -> AnyElement { + div() + .px(px(10.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(button_bg())) + .child(Input::new(state).appearance(false).cleanable(false)) + .into_any_element() } -fn short_message(message: &str) -> String { - const MAX_LEN: usize = 72; - if message.len() <= MAX_LEN { - return message.to_string(); - } - let truncated: String = message.chars().take(MAX_LEN - 1).collect(); - format!("{truncated}…") +fn render_inline_error(message: &str) -> AnyElement { + div() + .text_size(px(11.5)) + .text_color(rgb(colors::error())) + .child(message.to_string()) + .into_any_element() } fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str { @@ -453,18 +238,6 @@ fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str { } } -fn sync_object_state_label(state: SyncObjectState) -> &'static str { - match state { - SyncObjectState::LocalOnly => "Local only", - SyncObjectState::Paused => "Paused", - SyncObjectState::PrivacyControlled => "Privacy controlled", - SyncObjectState::Synced => "Synced", - } -} - -fn pill_bg() -> u32 { - colors::pick(0xffffffb3, 0x1f1d1bb3) -} fn card_bg() -> u32 { colors::pick(0xffffffd9, 0x1f1d1bd9) } diff --git a/crates/ely_app/src/shell/internal_pages/updates.rs b/crates/ely_app/src/shell/internal_pages/updates.rs index 4316234..abbd2fc 100644 --- a/crates/ely_app/src/shell/internal_pages/updates.rs +++ b/crates/ely_app/src/shell/internal_pages/updates.rs @@ -1,5 +1,3 @@ -use std::env; - use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; use ely_domain::UpdatePolicy; @@ -7,18 +5,10 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ IconName, Selectable, Sizable, StyledExt, button::{Button, ButtonVariants}, - scroll::ScrollableElement, }; use super::{ElyShell, render_canvas_surface}; -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -const BUILD_REVISION: &str = env!("ELY_BUILD_REVISION"); -const RELEASE_MANIFEST_PATH: &str = "/api/releases/manifest"; -const RELEASE_SIGNATURE_PATH: &str = "/api/releases/signature"; -const RELEASE_MANIFEST_CACHE: &str = "release_manifest_cache"; -const RELEASE_INTEGRITY: &str = "SHA-256 + Ed25519"; - impl ElyShell { pub(super) fn render_updates_page( &mut self, @@ -32,116 +22,29 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_updates_header(snapshot)) - .child(render_updates_summary(snapshot.update_policy, cx)) - .child(render_update_policy_rows(snapshot.update_policy, cx)) - .child(render_update_contract_rows()), + .child(render_updates_header(cx)) + .child(render_update_policy_rows(snapshot.update_policy, cx)), ) } } -fn render_updates_header(snapshot: &BrowserSnapshot) -> AnyElement { +fn render_updates_header(cx: &mut gpui::Context) -> AnyElement { div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Updates")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::muted())) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::muted())) - .child(IconName::LoaderCircle) - .child(format!("Build {BUILD_REVISION}")), - ) - .into_any_element() -} - -fn render_updates_summary( - update_policy: UpdatePolicy, - cx: &mut gpui::Context, -) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::hairline())) - .bg(rgb(colors::canvas_soft())) - .px_4() - .py_3() .flex() .items_center() .justify_between() .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Updates")) .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::primary())).child(IconName::LoaderCircle)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child("Release Manifest Contract"), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(update_policy.detail()), - ), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(rgb(colors::success())) - .child(update_policy.name()), - ) - .child( - Button::new("reset-update-settings") - .ghost() - .xsmall() - .icon(IconName::Undo2) - .label("Reset") - .tooltip("Restore Update Defaults") - .on_click(cx.listener(|shell, _, _, cx| { - shell.reset_update_settings(cx); - })), - ), + Button::new("reset-update-settings") + .ghost() + .xsmall() + .icon(IconName::Undo2) + .label("Reset") + .tooltip("Restore Update Defaults") + .on_click(cx.listener(|shell, _, _, cx| { + shell.reset_update_settings(cx); + })), ) .into_any_element() } @@ -227,44 +130,6 @@ fn render_update_policy_row( .into_any_element() } -fn render_update_contract_rows() -> AnyElement { - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .overflow_y_scrollbar() - .border_t_1() - .border_color(rgb(colors::hairline())) - .child(update_row(IconName::Info, "Current Version", APP_VERSION, "Cargo package version")) - .child(update_row(IconName::GitHub, "Build Revision", BUILD_REVISION, "Git revision")) - .child(update_row( - IconName::Globe, - "Release Target", - release_target(), - "Platform and architecture", - )) - .child(update_row( - IconName::File, - "Manifest API", - RELEASE_MANIFEST_PATH, - format!("KV namespace: {RELEASE_MANIFEST_CACHE}"), - )) - .child(update_row( - IconName::File, - "Signature API", - signature_query_path(), - "Targeted release signature document", - )) - .child(update_row( - IconName::CircleCheck, - "Artifact Integrity", - RELEASE_INTEGRITY, - "Release manifest requires package hash and signature", - )) - .into_any_element() -} - fn policy_icon(selected: bool) -> IconName { if selected { IconName::CircleCheck } else { IconName::LoaderCircle } } @@ -276,74 +141,3 @@ fn policy_icon_color(selected: bool) -> u32 { fn policy_button_label(selected: bool) -> &'static str { if selected { "Active" } else { "Select" } } - -fn update_row( - icon: IconName, - label: &'static str, - value: impl Into, - detail: impl Into, -) -> AnyElement { - let value = value.into(); - let detail = detail.into(); - - div() - .py_3() - .border_b_1() - .border_color(rgb(colors::hairline())) - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::muted_soft())).child(icon)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .truncate() - .text_color(rgb(colors::ink())) - .child(label), - ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::muted())) - .child(detail), - ), - ), - ) - .child( - div() - .max_w(px(360.0)) - .truncate() - .text_sm() - .font_semibold() - .text_color(rgb(colors::ink())) - .child(value), - ) - .into_any_element() -} - -fn release_target() -> String { - format!("{} / {}", env::consts::OS, env::consts::ARCH) -} - -fn signature_query_path() -> String { - format!( - "{RELEASE_SIGNATURE_PATH}?platform={}&architecture={}&version={APP_VERSION}", - env::consts::OS, - env::consts::ARCH - ) -} diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index ecc9231..cd3fb52 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -10,8 +10,8 @@ use gpui::{ use super::chrome::command_match::visible_command_rows; use super::chrome::{ SANS_FAMILY, WorkspaceDisclosureAnchor, panel_bg, panel_shadow, render_command_overlay, - render_topbar as render_topbar_chrome, render_wallpaper, render_workspace_disclosure, - render_workspace_disclosure_backdrop, + render_macos_traffic_light_hitboxes, render_topbar as render_topbar_chrome, render_wallpaper, + render_workspace_disclosure, render_workspace_disclosure_backdrop, }; use super::sidebar::collapsed_sidebar_active; use super::{ElyShell, ShellState}; @@ -27,31 +27,47 @@ impl Render for ElyShell { match &self.state { ShellState::Ready(core) => match core.snapshot() { Ok(snapshot) => { - colors::set_mode(resolve_color_mode( - snapshot.appearance.theme_mode(), - appearance, - )); + apply_color_mode( + resolve_color_mode(snapshot.appearance.theme_mode(), appearance), + cx, + ); match active_tab_from_snapshot(&snapshot) { Some(active_tab) => self.render_browser(&snapshot, active_tab, window, cx), None => render_error("active tab missing from snapshot".to_string()), } } Err(error) => { - colors::set_mode(resolve_color_mode( - ely_domain::ThemeMode::default(), - appearance, - )); + apply_color_mode( + resolve_color_mode(ely_domain::ThemeMode::default(), appearance), + cx, + ); render_error(error.to_string()) } }, ShellState::StartupError(message) => { - colors::set_mode(resolve_color_mode(ely_domain::ThemeMode::default(), appearance)); + apply_color_mode( + resolve_color_mode(ely_domain::ThemeMode::default(), appearance), + cx, + ); render_error(message.clone()) } } } } +fn apply_color_mode(mode: colors::Mode, cx: &mut Context) { + colors::set_mode(mode); + + let component_mode = match mode { + colors::Mode::Light => gpui_component::ThemeMode::Light, + colors::Mode::Dark => gpui_component::ThemeMode::Dark, + }; + if gpui_component::Theme::global(cx).mode != component_mode { + gpui_component::Theme::change(component_mode, None, cx); + } + gpui_component::Theme::global_mut(cx).font_family = SANS_FAMILY.into(); +} + fn resolve_color_mode( theme_mode: ely_domain::ThemeMode, window_appearance: gpui::WindowAppearance, @@ -154,6 +170,7 @@ impl ElyShell { .child(render_workspace_disclosure(snapshot, anchor, cx)) }) .children(render_command_overlay(self, snapshot, cx)) + .child(render_macos_traffic_light_hitboxes(cx)) .into_any_element() } diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 2eef35a..adb1028 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -7,7 +7,7 @@ use ely_domain::{ use gpui::Context; use gpui_component::slider::SliderValue; -use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; +use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; use super::sync_state::{SyncStateUpdate, sync_platform_label}; use super::{ElyShell, ShellState}; @@ -269,12 +269,19 @@ impl ElyShell { return; }; let active_profile_id = snapshot.active_profile_id.clone(); + let active_profile_name = snapshot.active_profile_name.clone(); + let active_profile_kind = snapshot.active_profile_kind.clone(); let device_name = format!("ELY · {}", snapshot.active_profile_name); let Some(profile_root) = default_profile_data_root() else { tracing::warn!(target: "ely::sync", "profile data root is unavailable"); return; }; - let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + let profile_dir = sync_profile_data_dir( + &profile_root, + &active_profile_id, + &active_profile_name, + &active_profile_kind, + ); let bytes = match core.build_sync_snapshot_bytes() { Ok(bytes) => bytes, Err(error) => { diff --git a/crates/ely_app/src/shell/spaces.rs b/crates/ely_app/src/shell/spaces.rs index 8070b35..a436460 100644 --- a/crates/ely_app/src/shell/spaces.rs +++ b/crates/ely_app/src/shell/spaces.rs @@ -1,5 +1,6 @@ use std::time::SystemTime; +use ely_browser_core::BrowserCore; use ely_domain::{ProfileId, SpaceId}; use gpui::{Context, Window}; @@ -121,6 +122,22 @@ impl ElyShell { self.close_workspace_picker(cx); } + pub(crate) fn create_workspace_from_picker( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state { + let name = next_workspace_name(core); + if core.create_space(name.clone(), workspace_icon(&name), 0xf54e00).is_ok() { + self.workspace_picker_open = false; + self.sync_address_input(window, cx); + self.schedule_cloud_sync_upload(cx); + cx.notify(); + } + } + } + pub(super) fn on_select_previous_space( &mut self, _: &SelectPreviousSpace, @@ -135,3 +152,21 @@ impl ElyShell { } } } + +fn next_workspace_name(core: &BrowserCore) -> String { + let Ok(snapshot) = core.snapshot() else { + return "Workspace 1".to_string(); + }; + let mut index = snapshot.spaces.len() + 1; + loop { + let name = format!("Workspace {index}"); + if snapshot.spaces.iter().all(|space| space.name() != name) { + return name; + } + index += 1; + } +} + +fn workspace_icon(name: &str) -> String { + name.chars().next().map_or_else(|| "W".to_string(), |char| char.to_string()) +} diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs index f47d0bc..9283686 100644 --- a/crates/ely_app/src/shell/sync_state.rs +++ b/crates/ely_app/src/shell/sync_state.rs @@ -1,6 +1,6 @@ use std::{path::Path, time::Duration}; -use ely_domain::SyncConnectionState; +use ely_domain::{ProfileKind, SyncConnectionState}; use gpui::{Context, Timer}; use super::{ElyShell, ShellState, auth}; @@ -109,15 +109,21 @@ impl ElyShell { let Some(snapshot) = core.snapshot().ok() else { return false; }; - let active_profile_id = snapshot.active_profile_id.clone(); let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root() else { return false; }; - let profile_dir = crate::services::servo_profile_data::profile_data_dir( + let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir( &profile_root, - &active_profile_id, + &snapshot.active_profile_id, + &snapshot.active_profile_name, + &snapshot.active_profile_kind, ); + if snapshot.active_profile_name == "Default" + && matches!(snapshot.active_profile_kind, ProfileKind::Standard) + { + migrate_legacy_default_sync_dir(&profile_root, &profile_dir); + } let bearer_path = profile_dir.join("sync").join("bearer.token"); let bearer_present = bearer_token_file_present(&bearer_path); let state = if bearer_present { @@ -225,6 +231,50 @@ fn bearer_token_file_present(path: &Path) -> bool { std::fs::metadata(path).map(|metadata| metadata.len() > 0).unwrap_or(false) } +fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Path) { + let stable_sync_dir = stable_profile_dir.join("sync"); + if stable_sync_dir.exists() { + return; + } + + let Ok(entries) = std::fs::read_dir(profile_root) else { + return; + }; + for entry in entries.flatten() { + let candidate = entry.path().join("servo").join("sync"); + if candidate == stable_sync_dir { + continue; + } + if !bearer_token_file_present(&candidate.join("bearer.token")) { + continue; + } + if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) { + tracing::warn!( + target: "ely::sync", + error = %error, + source = %candidate.display(), + "legacy sync profile migration failed", + ); + } + return; + } +} + +fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let destination_path = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&entry.path(), &destination_path)?; + } else if file_type.is_file() { + std::fs::copy(entry.path(), destination_path)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::bearer_token_file_present; diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index db46935..71f9c79 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -224,8 +224,8 @@ impl ElyShell { { changed = true; } - if let Some(favicon_url) = metadata.favicon_url - && let Ok(true) = core.set_tab_favicon_key(&metadata.tab_id, favicon_url) + if let Some(favicon_key) = metadata.favicon_key + && let Ok(true) = core.set_tab_favicon_key(&metadata.tab_id, favicon_key) { changed = true; } diff --git a/crates/ely_app/src/shell/web_surface_metadata.rs b/crates/ely_app/src/shell/web_surface_metadata.rs index a2fa801..090c108 100644 --- a/crates/ely_app/src/shell/web_surface_metadata.rs +++ b/crates/ely_app/src/shell/web_surface_metadata.rs @@ -28,14 +28,14 @@ impl WebSurfaceMetadataTracker { /// One page's worth of metadata observed in a Ready frame. The /// controller applies these to the `BrowserTab` after the frame has -/// been swapped into the surface state. Title and favicon are +/// been swapped into the surface state. Title and favicon key are /// independent: navigation often settles the URL first, then Servo /// emits a title change a frame or two later. #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct WebSurfacePageMetadata { pub(super) tab_id: TabId, pub(super) title: Option, - pub(super) favicon_url: Option, + pub(super) favicon_key: Option, } impl WebSurfacePageMetadata { @@ -44,14 +44,14 @@ impl WebSurfacePageMetadata { title: Option, loaded_url: Option, ) -> Option { - let favicon_url = loaded_url + let favicon_key = loaded_url .as_deref() .and_then(|loaded| ely_domain::UrlText::parse(loaded).ok()) - .and_then(|url| url.favicon_url()); - if title.is_none() && favicon_url.is_none() { + .and_then(|url| url.favicon_key()); + if title.is_none() && favicon_key.is_none() { return None; } - Some(Self { tab_id: tab_id.clone(), title, favicon_url }) + Some(Self { tab_id: tab_id.clone(), title, favicon_key }) } } diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index bf21767..d5696bc 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -462,7 +462,7 @@ impl BrowserCore { fn refresh_tab_url_metadata(&mut self, tab_index: usize) -> Result<(), CoreError> { let title = tab_title(self.tabs[tab_index].url()); self.tabs[tab_index].set_title(title); - if let Some(favicon_key) = self.tabs[tab_index].url().favicon_url() { + if let Some(favicon_key) = self.tabs[tab_index].url().favicon_key() { self.tabs[tab_index].set_favicon_key(favicon_key)?; } else { self.tabs[tab_index].clear_favicon_key(); diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index d04333b..57f62e5 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; +use ely_domain::{CommandIntent, CommandScope, ProfileKind, SearchEngine, UrlText}; #[test] fn favorite_command_toggles_active_tab() -> Result<(), Box> { @@ -187,6 +187,22 @@ fn open_sync_status_command_opens_sync_status_page() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_search_engine(SearchEngine::Google); + core.set_command_query("servo browser"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Search("servo browser".to_string()))); + assert_eq!(active_tab.url().host().as_deref(), Some("www.google.com")); + assert_eq!(active_tab.url().as_str(), "https://www.google.com/search?q=servo+browser"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn settings_scoped_search_opens_about_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/tab_navigation.rs b/crates/ely_browser_core/tests/tab_navigation.rs index e78bc3c..1c2501b 100644 --- a/crates/ely_browser_core/tests/tab_navigation.rs +++ b/crates/ely_browser_core/tests/tab_navigation.rs @@ -61,10 +61,7 @@ fn navigation_replaces_new_tab_metadata_with_url_metadata() -> Result<(), Box Result<(), Box> { let active_tab = core.active_tab()?; assert_eq!(active_tab.url().as_str(), "https://example.com/a"); assert_eq!(active_tab.title(), "example.com"); - assert_eq!( - active_tab.favicon_key(), - Some("https://www.google.com/s2/favicons?domain=example.com&sz=64"), - ); + assert_eq!(active_tab.favicon_key(), Some("ely-favicon://example.com")); Ok(()) } diff --git a/crates/ely_domain/src/command.rs b/crates/ely_domain/src/command.rs index 799b9cc..6682e77 100644 --- a/crates/ely_domain/src/command.rs +++ b/crates/ely_domain/src/command.rs @@ -40,7 +40,11 @@ impl CommandIntent { return non_empty_text(query).map(|query| Self::ScopedSearch { scope, query }); } - UrlText::from_address_text(trimmed).map(Self::Navigate) + match UrlText::from_address_text(trimmed) { + Ok(url) => Ok(Self::Navigate(url)), + Err(DomainError::InvalidUrl { .. }) => Ok(Self::Search(trimmed.to_string())), + Err(error) => Err(error), + } } } @@ -68,3 +72,25 @@ fn non_empty_text(value: &str) -> Result { } Ok(trimmed.to_string()) } + +#[cfg(test)] +mod tests { + use super::CommandIntent; + + #[test] + fn plain_text_parses_as_search() { + assert_eq!( + CommandIntent::parse("servo browser").unwrap(), + CommandIntent::Search("servo browser".to_string()) + ); + } + + #[test] + fn domain_like_text_parses_as_navigation() { + let intent = CommandIntent::parse("example.com").unwrap(); + let CommandIntent::Navigate(url) = intent else { + panic!("expected navigation intent"); + }; + assert_eq!(url.as_str(), "https://example.com"); + } +} diff --git a/crates/ely_domain/src/url_text.rs b/crates/ely_domain/src/url_text.rs index 6b51c60..9055130 100644 --- a/crates/ely_domain/src/url_text.rs +++ b/crates/ely_domain/src/url_text.rs @@ -72,29 +72,17 @@ impl UrlText { url.host_str().map(str::to_string).unwrap_or_else(|| self.value.clone()) } - /// Resolve a favicon URL for an HTTP(S) page. Returns `None` for - /// non-web schemes (`ely://`, `file://`, …) and for URLs missing - /// an authority — those tabs render the URL-derived glyph instead. - /// - /// We deliberately do NOT point the renderer at the site's own - /// `/favicon.ico` because a) many sites only ship that icon as - /// a multi-image `image/x-icon` blob the renderer's PNG/WebP - /// decoder can't read, and b) the URL frequently 404s or - /// redirects across origins (notion.com → notion.so etc.) which - /// the GPUI image fetcher surfaces as a noisy `ERROR` log on - /// every tab. Instead we route through Google's `s2/favicons` - /// endpoint: it normalises the response to PNG, resolves - /// redirects on Google's side, and serves a `_/` globe glyph - /// when the target site has no favicon at all. Same URL shape - /// every browser dev-tools panel already shows for "favicon". + /// Resolve a stable favicon key for an HTTP(S) page. The UI uses + /// this as metadata and renders a local host-derived glyph, keeping + /// tab rows independent from network image fetches. #[must_use] - pub fn favicon_url(&self) -> Option { + pub fn favicon_key(&self) -> Option { let url = Url::parse(&self.value).ok()?; if !matches!(url.scheme(), "http" | "https") { return None; } let host = url.host_str()?; - Some(format!("https://www.google.com/s2/favicons?domain={host}&sz=64")) + Some(format!("ely-favicon://{host}")) } } diff --git a/crates/ely_sync_client/src/client.rs b/crates/ely_sync_client/src/client.rs index aa3d08e..90008aa 100644 --- a/crates/ely_sync_client/src/client.rs +++ b/crates/ely_sync_client/src/client.rs @@ -74,6 +74,7 @@ impl SyncApiClient { idempotency_key: &str, ) -> Result { let registration = DeviceRegistration { + version: 1, device_id: &identity.device_id, public_key: &identity.public_key, device_name: &identity.device_name, diff --git a/crates/ely_sync_client/src/device.rs b/crates/ely_sync_client/src/device.rs index b48c2f8..861ae5d 100644 --- a/crates/ely_sync_client/src/device.rs +++ b/crates/ely_sync_client/src/device.rs @@ -114,6 +114,7 @@ fn hex_string(bytes: &[u8]) -> String { #[derive(Clone, Debug, Serialize)] pub struct DeviceRegistration<'a> { + pub version: u32, pub device_id: &'a str, pub public_key: &'a str, pub device_name: &'a str, @@ -164,4 +165,23 @@ mod tests { assert_eq!(identity, again); Ok(()) } + + #[test] + fn device_registration_serializes_worker_schema_version() -> Result<(), SyncClientError> { + let registration = DeviceRegistration { + version: 1, + device_id: "device-01", + public_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + device_name: "MacBook Pro", + platform: "macOS", + idempotency_key: "device-register-device-01", + }; + + let value = serde_json::to_value(registration).map_err(|error| { + SyncClientError::TokenStorage(format!("device registration serialize: {error}")) + })?; + + assert_eq!(value["version"], 1); + Ok(()) + } } diff --git a/docs/servo-embedding-architecture.md b/docs/servo-embedding-architecture.md new file mode 100644 index 0000000..bd4d4e0 --- /dev/null +++ b/docs/servo-embedding-architecture.md @@ -0,0 +1,123 @@ +# Servo Embedding Architecture + +## Decision + +ELY is a Servo-based browser. The page renderer is Servo itself, embedded in the application process and attached to a real platform rendering surface. The browser chrome can stay GPUI, while web content must follow Servo's embedder model: + +```text +┌──────────────────────────── ELY App Process ────────────────────────────┐ +│ │ +│ GPUI chrome │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Sidebar Toolbar Tabs Settings │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Servo content host │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Servo + WebView + WindowRenderingContext │ │ +│ │ notify_new_frame_ready -> window repaint -> paint -> present │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +The normal page-display path excludes external rendering sidecars, stdout frame transport, RGBA frame payloads, cross-process IOSurface handoff, and GPUI `RenderImage` uploads for live web content. + +## Root Cause + +The current ELY page path is a remote-frame architecture: + +```text +GPUI shell + -> WebSurfaceStore + -> LiveRuntimeWorker + -> ServoLiveClient + -> ely_servo_sidecar stdin/stdout JSON + -> SoftwareServoHost + -> Servo WebView paint + -> RGBA payload or IOSurface handle + -> GPUI surface/image element +``` + +This makes page interaction depend on worker scheduling, IPC, polling cadence, surface import, frame object churn, and GPUI scene refresh. Hardware IOSurface reduces byte volume, while the architecture still behaves like a remote compositor. + +Servo's own embedder route is direct: + +```text +Window event + -> Servo spin_event_loop + -> WebViewDelegate::notify_new_frame_ready + -> window request_redraw + -> WebView::paint + -> RenderingContext::present +``` + +Relevant upstream evidence from Servo `7c48af7`: + +- `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`. +- `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`. +- `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint. +- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and explicitly avoids blocking paint on the constellation. + +## Target Boundaries + +```text +crates/ely_browser_core + Owns browser domain state: tabs, spaces, profiles, search, settings. + +crates/ely_app/src/shell + Owns GPUI chrome and command surfaces. + +crates/ely_app/src/servo_embed + Owns in-process Servo runtime, platform content view attachment, + WebView lifecycle, repaint dispatch, and web input routing. + +crates/ely_servo_host + Transitional compatibility surface for explicit screenshots and isolated + compatibility tools. Normal live page display leaves this crate. +``` + +Each production source file in the new embedding path stays below 500 lines. Large responsibilities split by ownership: + +- `runtime.rs`: `Servo`, wake handling, webview registry. +- `platform_view.rs`: platform content surface attachment. +- `delegate.rs`: Servo `WebViewDelegate` implementation. +- `input.rs`: GPUI event to Servo input conversion. +- `paint.rs`: repaint and present coordination. +- `metadata.rs`: title, URL, favicon, load-state propagation. + +## Migration Slices + +1. Create `servo_embed` as an in-process module behind the existing tab/domain model. +2. Add a macOS platform content view using the GPUI window's raw AppKit handle. +3. Build Servo `WindowRenderingContext` or child context against the platform content view. +4. Move one active tab to in-process `Servo + WebView + RenderingContext`. +5. Route scroll, mouse, keyboard, resize, zoom, and navigation directly into the active `WebView`. +6. Replace `WebSurfaceStore` for normal web pages with the in-process host. +7. Delete sidecar spawning from normal page display. +8. Keep explicit page screenshot capture as a user-command path only. + +## Acceptance Gates + +- `cargo run` opens a normal web page without starting `ely_servo_sidecar`. +- Scrolling a live web page uses Servo input events and Servo repaint callbacks. +- The page display path contains no RGBA frame payload transport. +- The page display path contains no stdout JSON frame loop. +- The page display path contains no GPUI `RenderImage` upload for live web content. +- Address/search, tabs, spaces, profiles, settings, permissions, sync, and explicit screenshots continue to compile and behave through existing domain APIs. +- Every new source file stays below 500 lines. +- No user-facing frontend status, logs, debug panels, or explanatory clutter are added. + +## First Implementation Target + +The first code slice is macOS content-view attachment: + +```text +GPUI Window + -> raw AppKit NSView + -> ELY child NSView for web content bounds + -> Servo WindowRenderingContext + -> Servo WebView +``` + +This slice creates the real platform surface required by Servo's direct rendering model. Once the content view exists, Servo can paint into a native surface in the ELY app process, and the remote-frame path can be removed tab by tab. diff --git a/docs/t10-iosurface-plan.md b/docs/t10-iosurface-plan.md deleted file mode 100644 index d30bd21..0000000 --- a/docs/t10-iosurface-plan.md +++ /dev/null @@ -1,208 +0,0 @@ -# T10 — Zero-copy live frames via OffscreenRenderingContext + IOSurface - -## Why this exists - -Today every live frame walks an entirely CPU-side pipeline: - -``` -Servo SoftwareRenderingContext (CPU rasterise) - → read_to_image: GPU(software)→CPU RGBA8 (8 MB / 1080p) - → stdout pipe (one memcpy into kernel buffer, one out) - → ServoLiveFrame::from_parts (move Vec, no copy) - → AHasher 8 MB (~0.8 ms after the T10 hash swap) - → ImageBuffer::from_raw + Arc (cache hit reuses) - → GPUI uploads as Metal texture and samples it as an Image -``` - -`Software` rasterising is intrinsically slow — Servo's compositor -walks the display list on a CPU thread and writes pixels into a host -buffer. The compositor + readback together are most of a `paint()`'s -wall-clock cost at 1080p. Even with the rest of the pipeline polished -(file pipe gone in `a80d039`, host-side `Vec` clone gone in `e02c0fd`, -identical-frame texture reuse in `7f3b8b4`, AHasher hot path), the -fundamental work — drawing pixels with the CPU and then handing the -host CPU buffer to the GPU — is what makes scroll feel non-native. - -The roundtable (rounds 1 & 2 in this directory's git log) converged -on the same target: **let Servo paint to a GPU surface that the GPUI -window can sample directly, no host memory in the loop**. On macOS -that surface is an `IOSurface`. Brave/Chromium's GPU process -publishes IOSurfaces this way and the renderer process samples them -through a Mach port. - -## What Servo gives us today - -`servo-paint-api 0.1` exposes three `RenderingContext` constructors: - -| Type | Backing | Headless? | IOSurface-backed on macOS? | -|---|---|---|---| -| `SoftwareRenderingContext` | software surfman adapter, CPU pixel buffer | yes | no | -| `WindowRenderingContext` | hardware surfman adapter, surface bound to a `RawWindowHandle` | no — needs a real window | yes (CGL backend uses IOSurface) | -| `OffscreenRenderingContext` | child of a `WindowRenderingContext`, paints into a separate framebuffer and blits back via `render_to_parent_callback` | not standalone | inherits parent's backing | - -The hard constraint: **the only GPU-backed constructor requires a -`DisplayHandle + WindowHandle`**. Servo does not currently expose a -"headless hardware" rendering context that we could create from the -sidecar process without owning a window. - -`SurfmanRenderingContext`, the underlying type, is in the same file -and IS hardware-capable headless — `Connection::new() → -create_adapter() → SurfmanRenderingContext::new` with a `Generic` -surface type would give us a hardware-backed offscreen context. -But its constructor is `fn new` (private). Reaching it requires -either patching `servo-paint-api` upstream or vendoring a thin wrapper. - -## Target architecture - -``` -[Sidecar process] [Main GPUI process] -───────────────── ─────────────────── -WebView paints with GPUI Metal/Blade -hardware compositor ▲ - │ │ - ▼ sample external texture -OffscreenRenderingContext │ -(surfman hardware adapter) Metal MTLTexture - │ (backed by IOSurface) - ▼ ▲ -IOSurface (Generic surface, │ -CGL backend on macOS) │ - │ │ - ▼ │ -extract IOSurface mach port name ──── share via JSON header ───► - │ - ▼ - import IOSurface as Metal - texture (one-time per surface) -``` - -Per-frame: zero CPU memcpy, zero pipe traffic beyond the JSON header. -The sidecar only writes a small notification (`{"new_frame_seq": N, -"surface_id": "ioservice-port", "width": …, "height": …}`); the main -process re-samples the SAME texture (its contents have changed in -place). - -## Stepping stones - -### 1. Reorganise `ServoHost` to abstract over rendering-context kind - -Today `SoftwareServoHost` hard-wires `SoftwareRenderingContext`. Split -the host into: - - * `ServoHost` trait (existing) — describes the embedder API surface - * `SoftwareServoHost` (current) — keeps the CPU path running, no - behaviour change - * `HardwareServoHost` (new) — built on a hardware surfman context - -Both implement the same `ServoHost` trait so the sidecar binary picks -one via CLI flag or environment, and the `live.rs` plumbing doesn't -know which is active. This is purely a refactor with no perf change; -it unlocks step 2. - -### 2. Add a hardware headless rendering context - -The cleanest path is a tiny vendored adapter that exposes -`SurfmanRenderingContext::new` directly with `create_adapter()` and a -`Generic` `SurfaceType`. The Servo crate's private constructor means -we either: - - (a) **Upstream contribute**: open a Servo PR adding - `HardwareOffscreenRenderingContext` to `servo-paint-api`. Highest - quality option; long round-trip with Servo maintainers. - - (b) **Vendor the relevant types into `ely_servo_host`**: copy the - ~200 lines of `SurfmanRenderingContext` glue with a - `pub fn new_headless_hardware(...)` constructor. Keeps the - change inside our tree; risk is drifting against upstream. - - (c) **Open an upstream RFC for the API gap** while shipping (b) - behind a feature flag, with the explicit intent of removing it - once Servo merges (a). - -Recommend (c): ship (b) under `feature = "iosurface"`, keep -`SoftwareServoHost` as the default until upstream lands. - -### 3. macOS: extract the IOSurface from the surfman surface - -surfman exposes the raw native handle on macOS via -`surfman::Surface::native_id()`. On the CGL backend the underlying -storage is an `IOSurface`. We need the `IOSurfaceRef`'s **mach port -name** (`IOSurfaceCreateMachPort`) to share it across processes. -This is a few lines of `core-foundation` + `objc2-io-surface` FFI. - -### 4. Plumb the IOSurface mach port from sidecar to main - -Extend the `LiveResponse` JSON header with an optional -`surface_handle: Option` where -`IOSurfaceHandle { mach_port_name: u32, width: u32, height: u32 }`. -On the FIRST frame after a resize the sidecar publishes a new handle; -subsequent frames reuse the same handle (the IOSurface contents have -been overwritten in place by the GPU, no further protocol needed). - -### 5. Main process: import IOSurface as Metal external texture - -GPUI uses Blade (or wgpu) as its render backend. Blade's Metal -backend has `Texture::from_iosurface` (or wgpu's -`Device::create_texture_from_hal` with a Metal hal texture built -from `MTLDevice::newTextureWithDescriptor:iosurface:plane:`). The -GPUI side needs: - - * a small bridge crate (or unsafe block) that wraps the mach port - → `IOSurfaceRef` → `MTLTexture` chain - * an `ImageSource` variant that carries an MTLTexture handle and - bypasses the `RenderImage` + `ImageBuffer` allocation chain - -The latter is the biggest reach into GPUI's public surface. Likely -needs an upstream gpui contribution or a local fork. - -### 6. Replace the `Arc` path for live frames - -`WebSurfaceFrame::image: Arc` becomes an enum: - -```rust -enum WebSurfaceImage { - Software(Arc), // fallback path, T6 hash dedup applies - Hardware(MetalTextureHandle), // zero-copy path -} -``` - -`render_ready_web_surface` chooses the right `img(...)` / Metal -sampler based on the variant. - -## Risks & open questions - - * **Servo upstream API gap** is the gating issue. Without step 2 - landing somehow, none of the rest is possible from a clean - sidecar process. - * **Cross-process IOSurface lifecycle**: if the sidecar crashes - while the main process still holds an `MTLTexture`, the texture - is dangling. Mach ports survive briefly; we need a "surface - invalidated" notification on the IPC channel. - * **GPU adapter compatibility**: surfman's hardware adapter on - macOS picks the integrated GPU by default. GPUI may pick a - discrete GPU. Mismatched adapters → IOSurface import either fails - or silently corrupts. Need to either query GPUI's chosen adapter - and force surfman to match, or use the system's default for both. - * **Windows / Linux**: IOSurface is macOS-only. The same concept - on Windows is `IDXGIResource1::CreateSharedHandle`; on Linux it's - `EGL_EXT_image_dma_buf_import`. Each platform needs its own - bridge; the JSON protocol stays the same, the bridge differs. - * **Software fallback stays in**: not just because step 2 is - blocked, but because some environments (CI, headless tests, - sandboxed Mac App Store builds) may not allow GPU contexts. - -## Already shipped on this branch - -| Commit | Move | -|---|---| -| `840255f` | Lift web canvas out of in-flow so input_overlay lands on screen (prerequisite for input to work at all) | -| `a80d039` | Drop the file system from the live frame pixel pipe (8 MB syscall round-trip → in-process pipe) | -| `e02c0fd` | Drop the sidecar's per-frame `to_vec()` clone (extra 8 MB memcpy gone) | -| `7f3b8b4` | Dedup byte-identical RGBA payloads against the last frame's `Arc` (idle pages stop re-uploading) | -| AHasher swap | SipHash13 → AHash for the dedup key (~5 ms → ~0.8 ms per cache-miss frame at 1080p) | - -Each of these is a stepping stone; the IOSurface path eventually -deletes most of them (the host-side `Vec` lifecycle disappears -when GPU memory is the source of truth), but they make the current -software path's tail latency tolerable while the architectural work -above gets staged.