Fix shell regressions and reset Servo embedding direction

This commit is contained in:
2026-05-16 11:23:41 -04:00
parent 7823ecd0a3
commit d076dad356
36 changed files with 683 additions and 1919 deletions
+1 -6
View File
@@ -11,7 +11,6 @@ use std::{
time::Duration, time::Duration,
}; };
use ely_design_system::spacing;
use ely_domain::UrlText; use ely_domain::UrlText;
use gpui::{ use gpui::{
AnyWindowHandle, App, AppContext, Application, Bounds, Entity, Focusable, Menu, MenuItem, AnyWindowHandle, App, AppContext, Application, Bounds, Entity, Focusable, Menu, MenuItem,
@@ -19,6 +18,7 @@ use gpui::{
}; };
use gpui_component_assets::Assets; use gpui_component_assets::Assets;
use shell::ElyShell; use shell::ElyShell;
use shell::chrome::{TRAFFIC_LIGHT_ORIGIN_X, TRAFFIC_LIGHT_ORIGIN_Y};
use shortcuts::bind_shortcuts; use shortcuts::bind_shortcuts;
use crate::brand::{DEEP_LINK_PREFIX, PRODUCT_NAME}; 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() { fn main() {
init_tracing(); init_tracing();
let pending_deep_links = PendingDeepLinks::default(); let pending_deep_links = PendingDeepLinks::default();
@@ -5,11 +5,12 @@ use std::{
}; };
use directories::ProjectDirs; use directories::ProjectDirs;
use ely_domain::ProfileId; use ely_domain::{ProfileId, ProfileKind};
const ELY_QUALIFIER: &str = "com"; const ELY_QUALIFIER: &str = "com";
const ELY_ORGANIZATION: &str = "elydora"; const ELY_ORGANIZATION: &str = "elydora";
const ELY_APPLICATION: &str = "ELY Browser"; const ELY_APPLICATION: &str = "ELY Browser";
const DEFAULT_STANDARD_PROFILE_DIR: &str = "default";
pub(crate) fn default_profile_data_root() -> Option<PathBuf> { pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION) 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") 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( pub(crate) fn transient_profile_data_dir(
profile_id: &ProfileId, profile_id: &ProfileId,
) -> Result<PathBuf, SystemTimeError> { ) -> Result<PathBuf, SystemTimeError> {
@@ -36,3 +49,32 @@ pub(crate) enum ProfileDataMode {
Persistent, Persistent,
Transient, 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)
);
}
}
+31 -20
View File
@@ -10,10 +10,11 @@
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use ely_browser_core::SyncEngine; use ely_browser_core::SyncEngine;
use ely_domain::{ProfileId, ProfileKind};
use ely_sync_client::{ApiClientConfig, BearerToken, send_email_otp, verify_email_otp}; use ely_sync_client::{ApiClientConfig, BearerToken, send_email_otp, verify_email_otp};
use gpui::Context; 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::sync_state::{SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
@@ -44,16 +45,6 @@ pub(crate) enum AuthFlowPhase {
} }
impl 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> { pub(crate) fn error_message(&self) -> Option<&str> {
match self { match self {
Self::Error { message, .. } => Some(message.as_str()), Self::Error { message, .. } => Some(message.as_str()),
@@ -102,8 +93,8 @@ impl ElyShell {
AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() }; AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() };
return; return;
} }
let active_profile_id = match active_profile_id_for(&self.state) { let active_profile = match active_profile_sync_context_for(&self.state) {
Some(id) => id, Some(profile) => profile,
None => return, None => return,
}; };
let Some(profile_root) = default_profile_data_root() else { let Some(profile_root) = default_profile_data_root() else {
@@ -113,7 +104,12 @@ impl ElyShell {
}; };
return; 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() }; self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
let tx = self.sync_inbox_tx.clone(); let tx = self.sync_inbox_tx.clone();
spawn_verify_otp(email, normalized_otp, profile_dir, tx); 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. /// call to make, the token is the only artefact we own.
pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) { pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) {
self.auth_flow_phase = AuthFlowPhase::Idle; self.auth_flow_phase = AuthFlowPhase::Idle;
let active_profile_id = match active_profile_id_for(&self.state) { let active_profile = match active_profile_sync_context_for(&self.state) {
Some(id) => id, Some(profile) => profile,
None => return, None => return,
}; };
let Some(profile_root) = default_profile_data_root() else { let Some(profile_root) = default_profile_data_root() else {
return; 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()) { match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
Ok(mut engine) => { Ok(mut engine) => {
let _ = engine.install_bearer(""); let _ = engine.install_bearer("");
@@ -162,11 +163,22 @@ fn normalize_email(raw: &str) -> Option<String> {
Some(trimmed.to_lowercase()) Some(trimmed.to_lowercase())
} }
fn active_profile_id_for(state: &ShellState) -> Option<ely_domain::ProfileId> { #[derive(Clone, Debug, Eq, PartialEq)]
struct ActiveProfileSyncContext {
id: ProfileId,
name: String,
kind: ProfileKind,
}
fn active_profile_sync_context_for(state: &ShellState) -> Option<ActiveProfileSyncContext> {
let ShellState::Ready(core) = state else { let ShellState::Ready(core) = state else {
return None; 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<SyncStateUpdate>) { fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
@@ -253,7 +265,6 @@ mod tests {
#[test] #[test]
fn auth_phase_helpers() { fn auth_phase_helpers() {
let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() }; let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() };
assert_eq!(phase.email(), Some("you@there"));
assert!(phase.is_busy()); assert!(phase.is_busy());
assert_eq!(phase.error_message(), None); assert_eq!(phase.error_message(), None);
+4
View File
@@ -16,6 +16,7 @@ pub(crate) mod split_pane;
pub(crate) mod topbar; pub(crate) mod topbar;
pub(crate) mod typography; pub(crate) mod typography;
pub(crate) mod wallpaper; pub(crate) mod wallpaper;
pub(crate) mod window_traffic_lights;
pub(crate) use appearance_form::render_appearance_form; pub(crate) use appearance_form::render_appearance_form;
pub(crate) use brand_glyph::{accent_color_for_host, render_glyph_for}; 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 topbar::render_topbar;
pub(crate) use typography::{SANS_FAMILY, SERIF_FAMILY, register_serif_fonts}; pub(crate) use typography::{SANS_FAMILY, SERIF_FAMILY, register_serif_fonts};
pub(crate) use wallpaper::render_wallpaper; 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,
};
@@ -100,7 +100,7 @@ const NAV_GROUPS: &[NavGroup] = &[
/// right swapped" instead of "the layout disappeared and a new tab /// right swapped" instead of "the layout disappeared and a new tab
/// opened" — the prior behavior that misread to users as a tab spawn. /// opened" — the prior behavior that misread to users as a tab spawn.
pub(crate) fn render_settings_shell( pub(crate) fn render_settings_shell(
snapshot: &BrowserSnapshot, _snapshot: &BrowserSnapshot,
active_route: &str, active_route: &str,
content: AnyElement, content: AnyElement,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
@@ -109,16 +109,12 @@ pub(crate) fn render_settings_shell(
.flex_1() .flex_1()
.h_full() .h_full()
.flex() .flex()
.child(render_nav_column(snapshot, active_route, cx)) .child(render_nav_column(active_route, cx))
.child(content) .child(content)
.into_any_element() .into_any_element()
} }
fn render_nav_column( fn render_nav_column(active_route: &str, cx: &mut Context<ElyShell>) -> AnyElement {
snapshot: &BrowserSnapshot,
active_route: &str,
cx: &mut Context<ElyShell>,
) -> AnyElement {
div() div()
.w(px(232.0)) .w(px(232.0))
.h_full() .h_full()
@@ -132,7 +128,7 @@ fn render_nav_column(
.flex_col() .flex_col()
.gap(px(2.0)) .gap(px(2.0))
.overflow_y_scrollbar() .overflow_y_scrollbar()
.child(render_nav_brand(snapshot)) .child(render_nav_brand())
.children( .children(
NAV_GROUPS NAV_GROUPS
.iter() .iter()
@@ -142,13 +138,10 @@ fn render_nav_column(
.into_any_element() .into_any_element()
} }
fn render_nav_brand(snapshot: &BrowserSnapshot) -> AnyElement { fn render_nav_brand() -> AnyElement {
div() div()
.px(px(12.0)) .px(px(12.0))
.pb(px(12.0)) .pb(px(12.0))
.flex()
.flex_col()
.gap_1()
.child( .child(
div() div()
.text_size(px(18.0)) .text_size(px(18.0))
@@ -156,12 +149,6 @@ fn render_nav_brand(snapshot: &BrowserSnapshot) -> AnyElement {
.text_color(rgb(colors::ink())) .text_color(rgb(colors::ink()))
.child("Settings"), .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() .into_any_element()
} }
+6 -24
View File
@@ -2,9 +2,9 @@ use ely_browser_core::BrowserSnapshot;
use ely_design_system::{colors, spacing}; use ely_design_system::{colors, spacing};
use ely_domain::BrowserTab; use ely_domain::BrowserTab;
use gpui::{ use gpui::{
AnyElement, Context, FontWeight, ImageSource, InteractiveElement, IntoElement, ObjectFit, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
ParentElement, SharedString, StatefulInteractiveElement, Styled, StyledImage, div, hsla, img, StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient,
linear_color_stop, linear_gradient, prelude::FluentBuilder, px, rgb, rgba, prelude::FluentBuilder, px, rgb, rgba,
}; };
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
@@ -464,30 +464,12 @@ where
chrome_motion_feedback(press_id, selection_id, false, element) chrome_motion_feedback(press_id, selection_id, false, element)
} }
/// Resolve the favicon glyph for a tab row. Prefers the favicon URL /// Resolve the favicon glyph for a tab row. Favicon metadata stays as
/// the Servo runtime derived from the loaded URL; falls back to the /// a local key; rows render the host-derived glyph so the sidebar never
/// initial-letter chip used everywhere else when the tab has no live /// blocks on network image assets.
/// favicon (yet to load, internal page, file URL, etc.).
fn render_tab_favicon(tab: &BrowserTab, initial: &str) -> AnyElement { 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(); let host = tab.url().host();
render_glyph_for(host.as_deref(), initial, FAVICON_SIZE) render_glyph_for(host.as_deref(), initial, FAVICON_SIZE)
} }
const FAVICON_SIZE: f32 = 16.0; const FAVICON_SIZE: f32 = 16.0;
const FAVICON_RADIUS: f32 = 4.0;
@@ -197,6 +197,12 @@ pub(crate) fn render_workspace_disclosure(
.border_1() .border_1()
.border_color(rgba(disclosure_border())) .border_color(rgba(disclosure_border()))
.shadow(soft_shadow()) .shadow(soft_shadow())
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|_, _: &gpui::MouseDownEvent, _, cx| {
cx.stop_propagation();
}),
)
.children( .children(
snapshot snapshot
.spaces .spaces
@@ -333,8 +339,7 @@ fn render_new_workspace_row(cx: &mut Context<ElyShell>) -> AnyElement {
.hover(|style| style.bg(rgba(disclosure_row_hover_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(disclosure_row_hover_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.85)) .active(|style| style.opacity(0.85))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(|shell, _, window, cx| {
shell.close_workspace_picker(cx); shell.create_workspace_from_picker(window, cx);
shell.open_internal_tab("ely://settings/spaces", window, cx);
})) }))
.child(div().text_color(rgb(colors::ink_3())).child(IconName::Plus)) .child(div().text_color(rgb(colors::ink_3())).child(IconName::Plus))
.child(div().flex_1().min_w_0().truncate().child("New workspace")) .child(div().flex_1().min_w_0().truncate().child("New workspace"))
@@ -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<ElyShell>) -> 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<ElyShell>) -> 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(),
}
}
@@ -17,7 +17,6 @@ mod plugin_catalog;
mod plugin_details; mod plugin_details;
mod plugin_editors_pick; mod plugin_editors_pick;
mod plugins; mod plugins;
mod privacy_data_inventory;
mod privacy_security; mod privacy_security;
mod profiles; mod profiles;
mod reading_list; mod reading_list;
@@ -15,89 +15,19 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_advanced_header(snapshot)) .child(render_advanced_header())
.child(render_advanced_summary(snapshot))
.child(render_advanced_rows(snapshot)), .child(render_advanced_rows(snapshot)),
) )
} }
} }
fn render_advanced_header(snapshot: &BrowserSnapshot) -> AnyElement { fn render_advanced_header() -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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"))
.into_any_element() .into_any_element()
} }
@@ -132,24 +62,6 @@ fn render_advanced_rows(snapshot: &BrowserSnapshot) -> AnyElement {
download_policy_label(&snapshot.active_download_policy), download_policy_label(&snapshot.active_download_policy),
"Active Profile download destination 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() .into_any_element()
} }
@@ -236,11 +148,3 @@ fn archive_policy_label(policy: &ArchivePolicy) -> &'static str {
ArchivePolicy::IdleDays(_) => "Custom", 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
}
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use directories::UserDirs; use directories::UserDirs;
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors; 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::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
use gpui_component::{ use gpui_component::{
IconName, Selectable, Sizable, StyledExt, IconName, Selectable, Sizable, StyledExt,
@@ -11,7 +11,7 @@ use gpui_component::{
scroll::ScrollableElement, scroll::ScrollableElement,
}; };
use super::{ElyShell, download_labels::download_policy_label, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
#[derive(Clone)] #[derive(Clone)]
struct DownloadPolicyOption { struct DownloadPolicyOption {
@@ -36,104 +36,19 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_download_settings_header(snapshot)) .child(render_download_settings_header(cx))
.child(render_download_policy_summary(snapshot, cx))
.child(render_download_policy_rows(&snapshot.active_download_policy, &options, 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<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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<ElyShell>,
) -> 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::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( .child(
Button::new("reset-download-settings") Button::new("reset-download-settings")
.ghost() .ghost()
@@ -144,7 +59,6 @@ fn render_download_policy_summary(
.on_click(cx.listener(|shell, _, _, cx| { .on_click(cx.listener(|shell, _, _, cx| {
shell.reset_active_profile_download_settings(cx); shell.reset_active_profile_download_settings(cx);
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -261,13 +175,6 @@ fn user_downloads_dir() -> Option<PathBuf> {
UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) 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 { fn download_policy_icon(option: &DownloadPolicyOption, selected: bool) -> IconName {
if selected { IconName::CircleCheck } else { option.icon.clone() } if selected { IconName::CircleCheck } else { option.icon.clone() }
} }
@@ -23,106 +23,19 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_general_header(snapshot)) .child(render_general_header(cx))
.child(render_general_summary(snapshot.new_tab_destination, cx))
.child(render_new_tab_destinations(snapshot.new_tab_destination, 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<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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<ElyShell>,
) -> 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(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( .child(
Button::new("reset-general-settings") Button::new("reset-general-settings")
.ghost() .ghost()
@@ -133,7 +46,6 @@ fn render_general_summary(
.on_click(cx.listener(|shell, _, _, cx| { .on_click(cx.listener(|shell, _, _, cx| {
shell.reset_general_settings(cx); shell.reset_general_settings(cx);
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<AnyElement> {
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()
}
@@ -9,7 +9,7 @@ use gpui_component::{
scroll::ScrollableElement, scroll::ScrollableElement,
}; };
use super::{ElyShell, privacy_data_inventory::render_local_data_inventory, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
impl ElyShell { impl ElyShell {
pub(super) fn render_privacy_security_page( pub(super) fn render_privacy_security_page(
@@ -27,115 +27,22 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_privacy_header(snapshot)) .child(render_privacy_header(cx))
.child(render_history_summary(snapshot, cx))
.when(snapshot.active_profile_history_entry_count > 0, |this| { .when(snapshot.active_profile_history_entry_count > 0, |this| {
this.child(render_history_clear_controls(confirming_clear, cx)) 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)), .child(render_privacy_settings_rows(snapshot, cx)),
) )
} }
} }
fn render_privacy_header(snapshot: &BrowserSnapshot) -> AnyElement { fn render_privacy_header(cx: &mut Context<ElyShell>) -> AnyElement {
div() 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<ElyShell>) -> AnyElement {
div()
.rounded_md()
.border_1()
.border_color(rgb(colors::hairline()))
.bg(rgb(colors::canvas_soft()))
.px_4()
.py_3()
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_between()
.gap_4() .gap_4()
.child( .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Privacy & Security"))
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( .child(
Button::new("reset-privacy-settings") Button::new("reset-privacy-settings")
.ghost() .ghost()
@@ -146,7 +53,6 @@ fn render_history_summary(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>
.on_click(cx.listener(|shell, _, _, cx| { .on_click(cx.listener(|shell, _, _, cx| {
shell.reset_privacy_settings(cx); shell.reset_privacy_settings(cx);
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -159,14 +65,7 @@ fn render_history_clear_controls(confirming_clear: bool, cx: &mut Context<ElyShe
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_end()
.gap_4()
.child(
div()
.text_sm()
.text_color(rgb(colors::muted()))
.child("Clear all history saved for this Profile."),
)
.child( .child(
Button::new("request-clear-history") Button::new("request-clear-history")
.danger() .danger()
@@ -23,101 +23,19 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_search_header(snapshot)) .child(render_search_header(cx))
.child(render_search_summary(snapshot.search_engine, cx))
.child(render_search_engines(snapshot.search_engine, cx)), .child(render_search_engines(snapshot.search_engine, cx)),
) )
} }
} }
fn render_search_header(snapshot: &BrowserSnapshot) -> AnyElement { fn render_search_header(cx: &mut Context<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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<ElyShell>) -> 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::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( .child(
Button::new("reset-search-settings") Button::new("reset-search-settings")
.ghost() .ghost()
@@ -128,7 +46,6 @@ fn render_search_summary(search_engine: SearchEngine, cx: &mut Context<ElyShell>
.on_click(cx.listener(|shell, _, _, cx| { .on_click(cx.listener(|shell, _, _, cx| {
shell.reset_search_settings(cx); shell.reset_search_settings(cx);
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -1,5 +1,6 @@
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors; use ely_design_system::colors;
use gpui::prelude::FluentBuilder;
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
use gpui_component::{ use gpui_component::{
IconName, Sizable, StyledExt, IconName, Sizable, StyledExt,
@@ -18,7 +19,7 @@ const SHORTCUT_CATEGORIES: &[&str] = &["Command", "Tabs", "Library", "System", "
impl ElyShell { impl ElyShell {
pub(super) fn render_shortcuts_page( pub(super) fn render_shortcuts_page(
&mut self, &mut self,
snapshot: &BrowserSnapshot, _snapshot: &BrowserSnapshot,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let conflicts = self.shortcut_profile.conflicts(); let conflicts = self.shortcut_profile.conflicts();
@@ -30,64 +31,29 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_shortcuts_header(snapshot, conflicts.len(), cx)) .child(render_shortcuts_header(cx))
.child(render_shortcut_file_message( .child(render_shortcut_file_message(
self.shortcut_file_notice.as_deref(), self.shortcut_file_notice.as_deref(),
self.shortcut_file_error.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)), .child(render_shortcut_categories(&self.shortcut_profile, &conflicts)),
) )
} }
} }
fn render_shortcuts_header( fn render_shortcuts_header(cx: &mut Context<ElyShell>) -> AnyElement {
snapshot: &BrowserSnapshot,
conflict_count: usize,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let status = if conflict_count == 0 {
"Ready".to_string()
} else {
format!("{conflict_count} conflicts")
};
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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( .child(
div() div()
.flex() .flex()
.items_center() .items_center()
.gap_2() .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( .child(
Button::new("export-shortcuts") Button::new("export-shortcuts")
.ghost() .ghost()
@@ -311,7 +277,7 @@ fn render_shortcut_row(
.text_xs() .text_xs()
.child(shortcut_platform_label(profile, action, ShortcutPlatform::Macos)) .child(shortcut_platform_label(profile, action, ShortcutPlatform::Macos))
.child(shortcut_platform_label(profile, action, ShortcutPlatform::WindowsLinux)) .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() .into_any_element()
} }
@@ -336,19 +302,13 @@ fn shortcut_platform_label(
.into_any_element() .into_any_element()
} }
fn shortcut_row_status(has_conflict: bool) -> AnyElement { fn shortcut_row_status() -> AnyElement {
let (label, color) = div()
if has_conflict { ("Conflict", colors::error()) } else { ("Ready", colors::success()) }; .min_w(px(72.0))
.font_semibold()
div().min_w(px(72.0)).font_semibold().text_color(rgb(color)).child(label).into_any_element() .text_color(rgb(colors::error()))
} .child("Conflict")
.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_icon(has_conflict: bool) -> IconName { fn shortcut_row_icon(has_conflict: bool) -> IconName {
@@ -23,8 +23,7 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_site_permissions_header(snapshot)) .child(render_site_permissions_header())
.child(render_site_permissions_summary(snapshot))
.child(render_site_permissions_controls( .child(render_site_permissions_controls(
snapshot, snapshot,
self.site_permissions_clear_confirmation.as_ref() 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() 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() .flex()
.items_center() .items_center()
.justify_between() .justify_between()
.gap_4() .gap_4()
.children([ .child(div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Site Permissions"))
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()),
)
.into_any_element() .into_any_element()
} }
@@ -124,14 +61,7 @@ fn render_site_permissions_controls(
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_end()
.gap_4()
.child(
div()
.text_sm()
.text_color(rgb(colors::muted()))
.child("Clear all configured permissions for this Profile."),
)
.child( .child(
Button::new("request-clear-site-permissions") Button::new("request-clear-site-permissions")
.danger() .danger()
@@ -298,27 +228,6 @@ fn render_site_permission_entry(
.into_any_element() .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 { fn site_settings_route(origin: &SiteOrigin) -> String {
format!("ely://site/{}", origin.as_str()) format!("ely://site/{}", origin.as_str())
} }
@@ -1,9 +1,6 @@
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors; use ely_design_system::colors;
use ely_domain::{ use ely_domain::{SiteOrigin, SitePermissionDecision, SitePermissionFeature};
SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision,
SitePermissionFeature,
};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
use gpui_component::{ use gpui_component::{
@@ -40,18 +37,16 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_site_settings_header(snapshot, &origin)) .child(render_site_settings_header(&origin))
.child(render_site_permission_summary(snapshot, &origin)) .child(render_site_permission_rows(snapshot, &origin, cx)),
.child(render_site_permission_rows(snapshot, &origin, cx))
.child(render_site_permission_audit(snapshot, &origin)),
) )
} }
} }
fn render_site_settings_header(snapshot: &BrowserSnapshot, origin: &SiteOrigin) -> AnyElement { fn render_site_settings_header(origin: &SiteOrigin) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .gap_4()
.child( .child(
@@ -63,54 +58,13 @@ fn render_site_settings_header(snapshot: &BrowserSnapshot, origin: &SiteOrigin)
.child( .child(
div().text_size(px(26.0)).text_color(rgb(colors::ink())).child("Site Settings"), 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( .child(
div() div()
.flex() .text_sm()
.items_center() .truncate()
.gap_2()
.text_xs()
.font_semibold()
.text_color(rgb(colors::muted())) .text_color(rgb(colors::muted()))
.child(IconName::Globe) .child(origin.as_str().to_string()),
.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()),
) )
.into_any_element() .into_any_element()
} }
@@ -280,47 +234,6 @@ fn permission_reset_button(
.into_any_element() .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::<Vec<_>>();
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 { fn render_invalid_site_route() -> AnyElement {
div() div()
.size_full() .size_full()
@@ -357,38 +270,6 @@ fn decision_for(
.map(|entry| entry.decision()) .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 { fn decision_color(decision: SitePermissionDecision) -> u32 {
match decision { match decision {
SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => { SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => {
@@ -408,13 +289,6 @@ fn permission_icon(decision: Option<SitePermissionDecision>) -> 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 { fn feature_scope_label(feature: SitePermissionFeature) -> &'static str {
match feature { match feature {
SitePermissionFeature::Camera => "Controls camera capture requests.", SitePermissionFeature::Camera => "Controls camera capture requests.",
@@ -28,39 +28,24 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_spaces_header(snapshot, cx)) .child(render_spaces_header(cx))
.child(render_space_file_message( .child(render_space_file_message(
self.space_file_notice.as_deref(), self.space_file_notice.as_deref(),
self.space_file_error.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_spaces_list(snapshot, self.pending_space_trash.as_ref(), cx))
.child(render_trashed_spaces_list(snapshot, cx)), .child(render_trashed_spaces_list(snapshot, cx)),
) )
} }
} }
fn render_spaces_header(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement { fn render_spaces_header(cx: &mut Context<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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( .child(
div() div()
.flex() .flex()
@@ -95,17 +80,6 @@ fn render_spaces_header(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>)
cx, 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() .into_any_element()
@@ -136,60 +110,6 @@ fn render_space_file_message(notice: Option<&str>, error: Option<&str>) -> AnyEl
.into_any_element() .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( fn render_spaces_list(
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
pending_space_trash: Option<&SpaceId>, pending_space_trash: Option<&SpaceId>,
+64 -291
View File
@@ -1,21 +1,18 @@
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors; use ely_design_system::colors;
use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus}; use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectStatus};
use gpui::{ use gpui::{
AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba, 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::shell::auth::AuthFlowPhase;
use crate::brand::SYNC_SERVICE_NAME;
use super::sync_controls::{ use super::sync_controls::{
button_bg, render_dual_button_row, render_policy_toggle, render_primary_button, button_bg, render_dual_button_row, render_policy_toggle, render_primary_button,
render_reset_button, render_sign_out_button, render_reset_button, render_sign_out_button,
}; };
use super::{ElyShell, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
use crate::shell::chrome::SERIF_FAMILY;
impl ElyShell { impl ElyShell {
pub(super) fn render_sync_page( pub(super) fn render_sync_page(
@@ -24,109 +21,41 @@ impl ElyShell {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
render_canvas_surface( render_canvas_surface(
div().size_full().pt(px(40.0)).px(px(56.0)).pb(px(32.0)).flex().justify_center().child(
div() div()
.max_w(px(960.0)) .size_full()
.grid() .p(px(40.0))
.grid_cols(2) .flex()
.gap(px(32.0)) .justify_center()
.child(render_left_column(self, snapshot, cx)) .child(render_sync_body(self, snapshot, cx)),
.child(render_right_column(self, snapshot, cx)),
),
) )
} }
} }
fn render_left_column( fn render_sync_body(
shell: &mut ElyShell, shell: &mut ElyShell,
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
div() div()
.max_w(px(860.0))
.flex() .flex()
.flex_col() .flex_col()
.items_start() .gap(px(18.0))
.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()))
.child( .child(
"ELY keeps tabs, workspaces, pinned items, and history mirrored across your \
devices — encrypted in your hands and replayed at the edge.",
)
.into_any_element()
}
fn render_metrics_card(
shell: &ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
div() div()
.max_w(px(380.0)) .text_size(px(26.0))
.p(px(20.0)) .font_weight(FontWeight(500.0))
.rounded(px(16.0)) .text_color(rgb(colors::ink()))
.bg(rgba(card_bg())) .child("Sync"),
.flex() )
.flex_col()
.gap(px(16.0))
.child(div().text_size(px(12.5)).text_color(rgb(colors::ink_3())).child("Local queue"))
.child( .child(
div() div()
.grid() .grid()
.grid_cols(2) .grid_cols(2)
.gap(px(12.0)) .gap(px(18.0))
.child(render_metric( .child(render_account_card(shell, snapshot, cx))
"Pending", .child(render_data_card(shell, snapshot, cx)),
snapshot.sync_status.pending_objects(),
colors::ink(),
))
.child(render_metric(
"Failed",
snapshot.sync_status.failed_objects(),
colors::error(),
)),
) )
.child(render_reset_button(shell, cx))
.into_any_element() .into_any_element()
} }
@@ -135,27 +64,19 @@ fn render_account_card(
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
let card = div() let card =
.max_w(px(380.0)) div().p(px(18.0)).rounded(px(12.0)).bg(rgba(card_bg())).flex().flex_col().gap(px(14.0));
.p(px(20.0))
.rounded(px(16.0))
.bg(rgba(card_bg()))
.flex()
.flex_col()
.gap(px(14.0));
match snapshot.sync_status.connection() { match snapshot.sync_status.connection() {
SyncConnectionState::SignedOut => card SyncConnectionState::SignedOut => card
.child(render_account_heading("Sign in")) .child(render_card_heading("Account"))
.child(render_account_subtitle("We'll email a 6-digit code from browser@elydora.com."))
.children(account_form(shell, cx)) .children(account_form(shell, cx))
.into_any_element(), .into_any_element(),
SyncConnectionState::SignedIn SyncConnectionState::SignedIn
| SyncConnectionState::AwaitingDeviceApproval | SyncConnectionState::AwaitingDeviceApproval
| SyncConnectionState::SyncReady { .. } | SyncConnectionState::SyncReady { .. }
| SyncConnectionState::SyncError { .. } => card | SyncConnectionState::SyncError { .. } => card
.child(render_account_heading("Account")) .child(render_card_heading("Account"))
.child(render_signed_in_chip())
.child(render_sign_out_button(shell, cx)) .child(render_sign_out_button(shell, cx))
.into_any_element(), .into_any_element(),
} }
@@ -163,12 +84,10 @@ fn render_account_card(
fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement> { fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement> {
let mut elements: Vec<AnyElement> = Vec::new(); let mut elements: Vec<AnyElement> = Vec::new();
let phase = shell.auth_flow_phase.clone(); 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_field_label("Email"));
elements.push(render_input(&shell.auth_email_input, prefill_email.as_deref())); elements.push(render_input(&shell.auth_email_input));
match &phase { match &phase {
AuthFlowPhase::Idle | AuthFlowPhase::Error { .. } => { AuthFlowPhase::Idle | AuthFlowPhase::Error { .. } => {
@@ -184,18 +103,11 @@ fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement>
)); ));
} }
AuthFlowPhase::SendingCode { .. } => { AuthFlowPhase::SendingCode { .. } => {
elements.push(render_primary_button( elements.push(render_primary_button(shell, "send-otp", "Sending", true, cx, |_, _| {}));
shell,
"send-otp",
"Sending...",
true,
cx,
|_, _| {},
));
} }
AuthFlowPhase::AwaitingOtp { .. } | AuthFlowPhase::Verifying { .. } => { AuthFlowPhase::AwaitingOtp { .. } | AuthFlowPhase::Verifying { .. } => {
elements.push(render_account_label("Code")); elements.push(render_field_label("Code"));
elements.push(render_input(&shell.auth_otp_input, None)); elements.push(render_input(&shell.auth_otp_input));
elements.push(render_dual_button_row( elements.push(render_dual_button_row(
shell, shell,
phase.is_busy(), phase.is_busy(),
@@ -213,103 +125,14 @@ fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement>
elements elements
} }
fn render_account_heading(label: &str) -> AnyElement { fn render_data_card(
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<gpui_component::input::InputState>,
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<ElyShell>,
) -> 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(
shell: &ElyShell, shell: &ElyShell,
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
div() div()
.p(px(18.0)) .p(px(18.0))
.rounded(px(16.0)) .rounded(px(12.0))
.bg(rgba(card_bg())) .bg(rgba(card_bg()))
.flex() .flex()
.flex_col() .flex_col()
@@ -320,20 +143,10 @@ fn render_what_syncs_card(
div() div()
.flex() .flex()
.items_center() .items_center()
.gap(px(8.0)) .justify_between()
.child( .gap(px(10.0))
div() .child(render_card_heading("Data"))
.text_size(px(13.0)) .child(render_reset_button(shell, cx)),
.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())),
),
) )
.child( .child(
div().flex().flex_col().gap(px(2.0)).children( div().flex().flex_col().gap(px(2.0)).children(
@@ -357,86 +170,58 @@ fn render_sync_object_row(
div() div()
.flex() .flex()
.items_center() .items_center()
.gap(px(10.0)) .justify_between()
.py(px(8.0)) .gap(px(12.0))
.py(px(9.0))
.border_b_1() .border_b_1()
.border_color(rgba(colors::divider())) .border_color(rgba(colors::divider()))
.child(render_state_dot(status.state()))
.child( .child(
div() div()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_size(px(13.0)) .text_size(px(13.0))
.font_weight(FontWeight(500.0)) .font_weight(FontWeight(500.0))
.text_color(rgb(colors::ink())) .text_color(rgb(colors::ink()))
.child(sync_object_kind_label(status.kind())), .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())
))),
)
.child(render_policy_toggle(shell, index, status, cx)) .child(render_policy_toggle(shell, index, status, cx))
.into_any_element() .into_any_element()
} }
fn render_state_dot(state: SyncObjectState) -> AnyElement { fn render_card_heading(label: &'static str) -> AnyElement {
let color = match state { div()
SyncObjectState::LocalOnly => colors::ink_4(), .text_size(px(13.0))
SyncObjectState::Paused => colors::ink_5(), .font_weight(FontWeight(500.0))
SyncObjectState::PrivacyControlled => colors::accent(), .text_color(rgb(colors::ink()))
SyncObjectState::Synced => colors::success(), .child(label)
}; .into_any_element()
div().size(px(8.0)).rounded_full().bg(rgb(color)).into_any_element()
} }
fn connection_label(connection: &SyncConnectionState) -> String { fn render_field_label(label: &'static str) -> AnyElement {
match connection { div()
SyncConnectionState::SignedOut => "Local-only · drop a session token to enable".to_string(), .text_size(px(10.5))
SyncConnectionState::SignedIn => "Signed in · awaiting first sync".to_string(), .font_weight(FontWeight(500.0))
SyncConnectionState::AwaitingDeviceApproval => { .text_color(rgb(colors::ink_4()))
"Signed in · waiting for device approval".to_string() .child(label)
} .into_any_element()
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 relative_time_since(secs: u64) -> String { fn render_input(state: &gpui::Entity<gpui_component::input::InputState>) -> AnyElement {
use std::time::{Duration, SystemTime, UNIX_EPOCH}; div()
let when = UNIX_EPOCH + Duration::from_secs(secs); .px(px(10.0))
let elapsed = SystemTime::now().duration_since(when).unwrap_or_default(); .py(px(8.0))
let total_secs = elapsed.as_secs(); .rounded(px(8.0))
if total_secs < 60 { .bg(rgba(button_bg()))
return format!("{total_secs}s ago"); .child(Input::new(state).appearance(false).cleanable(false))
} .into_any_element()
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 short_message(message: &str) -> String { fn render_inline_error(message: &str) -> AnyElement {
const MAX_LEN: usize = 72; div()
if message.len() <= MAX_LEN { .text_size(px(11.5))
return message.to_string(); .text_color(rgb(colors::error()))
} .child(message.to_string())
let truncated: String = message.chars().take(MAX_LEN - 1).collect(); .into_any_element()
format!("{truncated}")
} }
fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str { 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 { fn card_bg() -> u32 {
colors::pick(0xffffffd9, 0x1f1d1bd9) colors::pick(0xffffffd9, 0x1f1d1bd9)
} }
@@ -1,5 +1,3 @@
use std::env;
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors; use ely_design_system::colors;
use ely_domain::UpdatePolicy; use ely_domain::UpdatePolicy;
@@ -7,18 +5,10 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb};
use gpui_component::{ use gpui_component::{
IconName, Selectable, Sizable, StyledExt, IconName, Selectable, Sizable, StyledExt,
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
scroll::ScrollableElement,
}; };
use super::{ElyShell, render_canvas_surface}; 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 { impl ElyShell {
pub(super) fn render_updates_page( pub(super) fn render_updates_page(
&mut self, &mut self,
@@ -32,105 +22,19 @@ impl ElyShell {
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap_5()
.child(render_updates_header(snapshot)) .child(render_updates_header(cx))
.child(render_updates_summary(snapshot.update_policy, cx)) .child(render_update_policy_rows(snapshot.update_policy, cx)),
.child(render_update_policy_rows(snapshot.update_policy, cx))
.child(render_update_contract_rows()),
) )
} }
} }
fn render_updates_header(snapshot: &BrowserSnapshot) -> AnyElement { fn render_updates_header(cx: &mut gpui::Context<ElyShell>) -> AnyElement {
div() div()
.flex() .flex()
.items_end() .items_center()
.justify_between() .justify_between()
.gap_4() .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_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<ElyShell>,
) -> 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::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( .child(
Button::new("reset-update-settings") Button::new("reset-update-settings")
.ghost() .ghost()
@@ -141,7 +45,6 @@ fn render_updates_summary(
.on_click(cx.listener(|shell, _, _, cx| { .on_click(cx.listener(|shell, _, _, cx| {
shell.reset_update_settings(cx); shell.reset_update_settings(cx);
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -227,44 +130,6 @@ fn render_update_policy_row(
.into_any_element() .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 { fn policy_icon(selected: bool) -> IconName {
if selected { IconName::CircleCheck } else { IconName::LoaderCircle } 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 { fn policy_button_label(selected: bool) -> &'static str {
if selected { "Active" } else { "Select" } if selected { "Active" } else { "Select" }
} }
fn update_row(
icon: IconName,
label: &'static str,
value: impl Into<String>,
detail: impl Into<String>,
) -> 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
)
}
+28 -11
View File
@@ -10,8 +10,8 @@ use gpui::{
use super::chrome::command_match::visible_command_rows; use super::chrome::command_match::visible_command_rows;
use super::chrome::{ use super::chrome::{
SANS_FAMILY, WorkspaceDisclosureAnchor, panel_bg, panel_shadow, render_command_overlay, SANS_FAMILY, WorkspaceDisclosureAnchor, panel_bg, panel_shadow, render_command_overlay,
render_topbar as render_topbar_chrome, render_wallpaper, render_workspace_disclosure, render_macos_traffic_light_hitboxes, render_topbar as render_topbar_chrome, render_wallpaper,
render_workspace_disclosure_backdrop, render_workspace_disclosure, render_workspace_disclosure_backdrop,
}; };
use super::sidebar::collapsed_sidebar_active; use super::sidebar::collapsed_sidebar_active;
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
@@ -27,31 +27,47 @@ impl Render for ElyShell {
match &self.state { match &self.state {
ShellState::Ready(core) => match core.snapshot() { ShellState::Ready(core) => match core.snapshot() {
Ok(snapshot) => { Ok(snapshot) => {
colors::set_mode(resolve_color_mode( apply_color_mode(
snapshot.appearance.theme_mode(), resolve_color_mode(snapshot.appearance.theme_mode(), appearance),
appearance, cx,
)); );
match active_tab_from_snapshot(&snapshot) { match active_tab_from_snapshot(&snapshot) {
Some(active_tab) => self.render_browser(&snapshot, active_tab, window, cx), Some(active_tab) => self.render_browser(&snapshot, active_tab, window, cx),
None => render_error("active tab missing from snapshot".to_string()), None => render_error("active tab missing from snapshot".to_string()),
} }
} }
Err(error) => { Err(error) => {
colors::set_mode(resolve_color_mode( apply_color_mode(
ely_domain::ThemeMode::default(), resolve_color_mode(ely_domain::ThemeMode::default(), appearance),
appearance, cx,
)); );
render_error(error.to_string()) render_error(error.to_string())
} }
}, },
ShellState::StartupError(message) => { 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()) render_error(message.clone())
} }
} }
} }
} }
fn apply_color_mode(mode: colors::Mode, cx: &mut Context<ElyShell>) {
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( fn resolve_color_mode(
theme_mode: ely_domain::ThemeMode, theme_mode: ely_domain::ThemeMode,
window_appearance: gpui::WindowAppearance, window_appearance: gpui::WindowAppearance,
@@ -154,6 +170,7 @@ impl ElyShell {
.child(render_workspace_disclosure(snapshot, anchor, cx)) .child(render_workspace_disclosure(snapshot, anchor, cx))
}) })
.children(render_command_overlay(self, snapshot, cx)) .children(render_command_overlay(self, snapshot, cx))
.child(render_macos_traffic_light_hitboxes(cx))
.into_any_element() .into_any_element()
} }
+9 -2
View File
@@ -7,7 +7,7 @@ use ely_domain::{
use gpui::Context; use gpui::Context;
use gpui_component::slider::SliderValue; 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::sync_state::{SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
@@ -269,12 +269,19 @@ impl ElyShell {
return; return;
}; };
let active_profile_id = snapshot.active_profile_id.clone(); 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 device_name = format!("ELY · {}", snapshot.active_profile_name);
let Some(profile_root) = default_profile_data_root() else { let Some(profile_root) = default_profile_data_root() else {
tracing::warn!(target: "ely::sync", "profile data root is unavailable"); tracing::warn!(target: "ely::sync", "profile data root is unavailable");
return; 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() { let bytes = match core.build_sync_snapshot_bytes() {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(error) => { Err(error) => {
+35
View File
@@ -1,5 +1,6 @@
use std::time::SystemTime; use std::time::SystemTime;
use ely_browser_core::BrowserCore;
use ely_domain::{ProfileId, SpaceId}; use ely_domain::{ProfileId, SpaceId};
use gpui::{Context, Window}; use gpui::{Context, Window};
@@ -121,6 +122,22 @@ impl ElyShell {
self.close_workspace_picker(cx); self.close_workspace_picker(cx);
} }
pub(crate) fn create_workspace_from_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
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( pub(super) fn on_select_previous_space(
&mut self, &mut self,
_: &SelectPreviousSpace, _: &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())
}
+54 -4
View File
@@ -1,6 +1,6 @@
use std::{path::Path, time::Duration}; use std::{path::Path, time::Duration};
use ely_domain::SyncConnectionState; use ely_domain::{ProfileKind, SyncConnectionState};
use gpui::{Context, Timer}; use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth}; use super::{ElyShell, ShellState, auth};
@@ -109,15 +109,21 @@ impl ElyShell {
let Some(snapshot) = core.snapshot().ok() else { let Some(snapshot) = core.snapshot().ok() else {
return false; return false;
}; };
let active_profile_id = snapshot.active_profile_id.clone();
let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root() let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root()
else { else {
return false; 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, &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_path = profile_dir.join("sync").join("bearer.token");
let bearer_present = bearer_token_file_present(&bearer_path); let bearer_present = bearer_token_file_present(&bearer_path);
let state = if bearer_present { 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::bearer_token_file_present; use super::bearer_token_file_present;
@@ -224,8 +224,8 @@ impl ElyShell {
{ {
changed = true; changed = true;
} }
if let Some(favicon_url) = metadata.favicon_url if let Some(favicon_key) = metadata.favicon_key
&& let Ok(true) = core.set_tab_favicon_key(&metadata.tab_id, favicon_url) && let Ok(true) = core.set_tab_favicon_key(&metadata.tab_id, favicon_key)
{ {
changed = true; changed = true;
} }
@@ -28,14 +28,14 @@ impl WebSurfaceMetadataTracker {
/// One page's worth of metadata observed in a Ready frame. The /// One page's worth of metadata observed in a Ready frame. The
/// controller applies these to the `BrowserTab` after the frame has /// 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 /// independent: navigation often settles the URL first, then Servo
/// emits a title change a frame or two later. /// emits a title change a frame or two later.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfacePageMetadata { pub(super) struct WebSurfacePageMetadata {
pub(super) tab_id: TabId, pub(super) tab_id: TabId,
pub(super) title: Option<String>, pub(super) title: Option<String>,
pub(super) favicon_url: Option<String>, pub(super) favicon_key: Option<String>,
} }
impl WebSurfacePageMetadata { impl WebSurfacePageMetadata {
@@ -44,14 +44,14 @@ impl WebSurfacePageMetadata {
title: Option<String>, title: Option<String>,
loaded_url: Option<String>, loaded_url: Option<String>,
) -> Option<Self> { ) -> Option<Self> {
let favicon_url = loaded_url let favicon_key = loaded_url
.as_deref() .as_deref()
.and_then(|loaded| ely_domain::UrlText::parse(loaded).ok()) .and_then(|loaded| ely_domain::UrlText::parse(loaded).ok())
.and_then(|url| url.favicon_url()); .and_then(|url| url.favicon_key());
if title.is_none() && favicon_url.is_none() { if title.is_none() && favicon_key.is_none() {
return None; return None;
} }
Some(Self { tab_id: tab_id.clone(), title, favicon_url }) Some(Self { tab_id: tab_id.clone(), title, favicon_key })
} }
} }
+1 -1
View File
@@ -462,7 +462,7 @@ impl BrowserCore {
fn refresh_tab_url_metadata(&mut self, tab_index: usize) -> Result<(), CoreError> { fn refresh_tab_url_metadata(&mut self, tab_index: usize) -> Result<(), CoreError> {
let title = tab_title(self.tabs[tab_index].url()); let title = tab_title(self.tabs[tab_index].url());
self.tabs[tab_index].set_title(title); 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)?; self.tabs[tab_index].set_favicon_key(favicon_key)?;
} else { } else {
self.tabs[tab_index].clear_favicon_key(); self.tabs[tab_index].clear_favicon_key();
+17 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{CommandIntent, CommandScope, ProfileKind, UrlText}; use ely_domain::{CommandIntent, CommandScope, ProfileKind, SearchEngine, UrlText};
#[test] #[test]
fn favorite_command_toggles_active_tab() -> Result<(), Box<dyn Error>> { fn favorite_command_toggles_active_tab() -> Result<(), Box<dyn Error>> {
@@ -187,6 +187,22 @@ fn open_sync_status_command_opens_sync_status_page() -> Result<(), Box<dyn Error
Ok(()) Ok(())
} }
#[test]
fn plain_text_command_searches_with_selected_engine() -> Result<(), Box<dyn Error>> {
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] #[test]
fn settings_scoped_search_opens_about_page() -> Result<(), Box<dyn Error>> { fn settings_scoped_search_opens_about_page() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
@@ -61,10 +61,7 @@ fn navigation_replaces_new_tab_metadata_with_url_metadata() -> Result<(), Box<dy
let active_tab = core.active_tab()?; let active_tab = core.active_tab()?;
assert_eq!(active_tab.title(), "example.com"); assert_eq!(active_tab.title(), "example.com");
assert_eq!( assert_eq!(active_tab.favicon_key(), Some("ely-favicon://example.com"));
active_tab.favicon_key(),
Some("https://www.google.com/s2/favicons?domain=example.com&sz=64"),
);
Ok(()) Ok(())
} }
@@ -95,10 +92,7 @@ fn history_navigation_refreshes_url_metadata() -> Result<(), Box<dyn Error>> {
let active_tab = core.active_tab()?; let active_tab = core.active_tab()?;
assert_eq!(active_tab.url().as_str(), "https://example.com/a"); assert_eq!(active_tab.url().as_str(), "https://example.com/a");
assert_eq!(active_tab.title(), "example.com"); assert_eq!(active_tab.title(), "example.com");
assert_eq!( assert_eq!(active_tab.favicon_key(), Some("ely-favicon://example.com"));
active_tab.favicon_key(),
Some("https://www.google.com/s2/favicons?domain=example.com&sz=64"),
);
Ok(()) Ok(())
} }
+27 -1
View File
@@ -40,7 +40,11 @@ impl CommandIntent {
return non_empty_text(query).map(|query| Self::ScopedSearch { scope, query }); 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<String, DomainError> {
} }
Ok(trimmed.to_string()) 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");
}
}
+5 -17
View File
@@ -72,29 +72,17 @@ impl UrlText {
url.host_str().map(str::to_string).unwrap_or_else(|| self.value.clone()) 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 /// Resolve a stable favicon key for an HTTP(S) page. The UI uses
/// non-web schemes (`ely://`, `file://`, …) and for URLs missing /// this as metadata and renders a local host-derived glyph, keeping
/// an authority — those tabs render the URL-derived glyph instead. /// tab rows independent from network image fetches.
///
/// 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".
#[must_use] #[must_use]
pub fn favicon_url(&self) -> Option<String> { pub fn favicon_key(&self) -> Option<String> {
let url = Url::parse(&self.value).ok()?; let url = Url::parse(&self.value).ok()?;
if !matches!(url.scheme(), "http" | "https") { if !matches!(url.scheme(), "http" | "https") {
return None; return None;
} }
let host = url.host_str()?; let host = url.host_str()?;
Some(format!("https://www.google.com/s2/favicons?domain={host}&sz=64")) Some(format!("ely-favicon://{host}"))
} }
} }
+1
View File
@@ -74,6 +74,7 @@ impl SyncApiClient {
idempotency_key: &str, idempotency_key: &str,
) -> Result<DeviceRecordDocument, SyncClientError> { ) -> Result<DeviceRecordDocument, SyncClientError> {
let registration = DeviceRegistration { let registration = DeviceRegistration {
version: 1,
device_id: &identity.device_id, device_id: &identity.device_id,
public_key: &identity.public_key, public_key: &identity.public_key,
device_name: &identity.device_name, device_name: &identity.device_name,
+20
View File
@@ -114,6 +114,7 @@ fn hex_string(bytes: &[u8]) -> String {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct DeviceRegistration<'a> { pub struct DeviceRegistration<'a> {
pub version: u32,
pub device_id: &'a str, pub device_id: &'a str,
pub public_key: &'a str, pub public_key: &'a str,
pub device_name: &'a str, pub device_name: &'a str,
@@ -164,4 +165,23 @@ mod tests {
assert_eq!(identity, again); assert_eq!(identity, again);
Ok(()) 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(())
}
} }
+123
View File
@@ -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.
-208
View File
@@ -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<RenderImage> (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<IOSurfaceHandle>` 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<RenderImage>` path for live frames
`WebSurfaceFrame::image: Arc<RenderImage>` becomes an enum:
```rust
enum WebSurfaceImage {
Software(Arc<RenderImage>), // 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<RenderImage>` (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<u8>` 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.