diff --git a/Cargo.lock b/Cargo.lock index 04c42a1..0785bde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2252,6 +2252,7 @@ dependencies = [ "gpui-component-assets", "sha2", "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", ] [[package]] diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index 1990af8..e8af6b3 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -17,5 +17,8 @@ gpui-component-assets.workspace = true sha2.workspace = true thiserror.workspace = true +[build-dependencies] +toml.workspace = true + [lints] workspace = true diff --git a/crates/ely_app/build.rs b/crates/ely_app/build.rs new file mode 100644 index 0000000..4c1b27c --- /dev/null +++ b/crates/ely_app/build.rs @@ -0,0 +1,122 @@ +use std::{ + env, + error::Error, + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::Command, +}; + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let workspace_root = workspace_root(&manifest_dir)?; + let workspace_manifest_path = workspace_root.join("Cargo.toml"); + let git_head_path = workspace_root.join(".git/HEAD"); + let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?; + let workspace = table(&workspace_manifest, "workspace")?; + let package = table(workspace, "package")?; + let dependencies = table(workspace, "dependencies")?; + + emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?; + emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?; + if let Some(ref_path) = git_head_ref(&git_head_path)? { + emit_cargo_directive(format!( + "cargo:rerun-if-changed={}", + workspace_root.join(".git").join(ref_path).display() + ))?; + } + + emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?; + emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?; + emit_env("ELY_GPUI_VERSION", dependency_version(dependencies, "gpui")?)?; + emit_env("ELY_GPUI_COMPONENT_VERSION", dependency_version(dependencies, "gpui-component")?)?; + emit_env("ELY_SERVO_VERSION", dependency_version(dependencies, "servo")?)?; + Ok(()) +} + +fn workspace_root(manifest_dir: &Path) -> Result<&Path, Box> { + let crates_dir = manifest_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing crates directory"))?; + crates_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing workspace root")) + .map_err(Into::into) +} + +fn read_workspace_manifest(path: &Path) -> Result> { + let manifest = fs::read_to_string(path)?; + manifest.parse::().map_err(Into::into) +} + +fn table<'a>(value: &'a toml::Table, key: &str) -> Result<&'a toml::Table, Box> { + value + .get(key) + .and_then(toml::Value::as_table) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("missing {key} table"))) + .map_err(Into::into) +} + +fn string_value<'a>(value: &'a toml::Table, key: &str) -> Result<&'a str, Box> { + value + .get(key) + .and_then(toml::Value::as_str) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("missing {key} value"))) + .map_err(Into::into) +} + +fn dependency_version<'a>( + dependencies: &'a toml::Table, + name: &str, +) -> Result<&'a str, Box> { + let dependency = dependencies.get(name).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, format!("missing {name} dependency")) + })?; + match dependency { + toml::Value::String(version) => Ok(version), + toml::Value::Table(table) => string_value(table, "version"), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid {name} dependency version"), + ) + .into()), + } +} + +fn git_revision(workspace_root: &Path) -> Result> { + let output = Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .current_dir(workspace_root) + .output()?; + if !output.status.success() { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "git revision is unavailable").into() + ); + } + let revision = String::from_utf8(output.stdout)?.trim().to_string(); + if revision.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "git revision is empty").into()); + } + Ok(revision) +} + +fn git_head_ref(git_head_path: &Path) -> Result, Box> { + let head = fs::read_to_string(git_head_path)?; + let Some(ref_path) = head.trim().strip_prefix("ref: ") else { + return Ok(None); + }; + Ok(Some(ref_path.to_string())) +} + +fn emit_env(key: &str, value: &str) -> Result<(), Box> { + if value.trim().is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{key} is empty")).into()); + } + emit_cargo_directive(format!("cargo:rustc-env={key}={value}"))?; + Ok(()) +} + +fn emit_cargo_directive(directive: impl AsRef) -> Result<(), Box> { + writeln!(io::stdout(), "{}", directive.as_ref())?; + Ok(()) +} diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 77864de..a9581ea 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,3 +1,4 @@ +mod about; mod download_actions; mod download_labels; mod downloads; @@ -27,6 +28,7 @@ impl ElyShell { "ely://downloads" => self.render_downloads_page(snapshot, cx), "ely://history" => self.render_history_page(snapshot, cx), "ely://archive" => self.render_archive_page(snapshot, cx), + "ely://about" => self.render_about_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), "ely://settings/sync" => self.render_sync_page(snapshot), diff --git a/crates/ely_app/src/shell/internal_pages/about.rs b/crates/ely_app/src/shell/internal_pages/about.rs new file mode 100644 index 0000000..1a90da3 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/about.rs @@ -0,0 +1,164 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ElyShell, render_canvas_surface}; + +const PRODUCT_NAME: &str = "ELY Browser"; +const COMPANY_NAME: &str = "Elydora"; +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const BUILD_REVISION: &str = env!("ELY_BUILD_REVISION"); +const WORKSPACE_LICENSE: &str = env!("ELY_WORKSPACE_LICENSE"); +const GPUI_VERSION: &str = env!("ELY_GPUI_VERSION"); +const GPUI_COMPONENT_VERSION: &str = env!("ELY_GPUI_COMPONENT_VERSION"); +const SERVO_VERSION: &str = env!("ELY_SERVO_VERSION"); + +impl ElyShell { + pub(super) fn render_about_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_about_header()) + .child(render_about_rows(snapshot)), + ) + } +} + +fn render_about_header() -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child(format!("About {PRODUCT_NAME}")), + ) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(COMPANY_NAME)), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Info) + .child(format!("Build {BUILD_REVISION}")), + ) + .into_any_element() +} + +fn render_about_rows(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .child(about_row(IconName::Building2, "Product", PRODUCT_NAME, COMPANY_NAME)) + .child(about_row(IconName::Info, "Version", APP_VERSION, "Cargo package version")) + .child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision")) + .child(about_row( + IconName::Frame, + "GPUI", + format!("gpui {GPUI_VERSION}"), + "Native desktop renderer", + )) + .child(about_row( + IconName::LayoutDashboard, + "Components", + format!("gpui-component {GPUI_COMPONENT_VERSION}"), + "UI component toolkit", + )) + .child(about_row( + IconName::Globe, + "Servo", + format!("servo {SERVO_VERSION}"), + "Browser engine crate", + )) + .child(about_row( + IconName::BookOpen, + "License", + WORKSPACE_LICENSE, + "Workspace package license", + )) + .child(about_row( + IconName::CircleUser, + "Runtime", + &snapshot.active_profile_name, + format!("{} - {} open tabs", snapshot.active_space_name, snapshot.tabs.len()), + )) + .into_any_element() +} + +fn about_row( + icon: IconName, + label: &'static str, + value: impl Into, + detail: impl Into, +) -> AnyElement { + let value = value.into(); + let detail = detail.into(); + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex_1() + .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(300.0)) + .truncate() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(value), + ) + .into_any_element() +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 1345958..35ca599 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -19,6 +19,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://downloads" => Some("Downloads"), "ely://history" => Some("History"), "ely://archive" => Some("Archived Tabs"), + "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/plugins" => Some("Plugin Settings"), "ely://settings/profiles" => Some("Profile Settings"), @@ -87,6 +88,10 @@ pub(crate) fn history_url() -> Result { internal_page_url("ely://history") } +pub(crate) fn about_url() -> Result { + internal_page_url("ely://about") +} + pub(crate) fn settings_url() -> Result { internal_page_url("ely://settings") } @@ -107,6 +112,7 @@ pub(crate) fn settings_page_url(query: &str) -> Result, CoreErro fn settings_page_route(query: &str) -> Option<&'static str> { match query { "settings" | "general" | "browser" => Some("ely://settings"), + "about" | "about ely browser" => Some("ely://about"), "sync" | "sync settings" => Some("ely://settings/sync"), "profile" | "profiles" | "profile settings" | "profiles settings" => { Some("ely://settings/profiles") diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 626653d..f8a3d79 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -3,9 +3,9 @@ use ely_domain::{CommandIntent, CommandScope, ProfileId, ProfileKind, SpaceId}; use crate::{ CoreError, navigation::{ - downloads_url, history_url, move_tab_space_name, new_profile_name, new_space_name, - search_url, settings_page_url, settings_url, space_icon, switch_profile_name, - sync_status_url, + about_url, downloads_url, history_url, move_tab_space_name, new_profile_name, + new_space_name, search_url, settings_page_url, settings_url, space_icon, + switch_profile_name, sync_status_url, }, }; @@ -106,6 +106,10 @@ impl BrowserCore { self.open_tab(history_url()?); Ok(true) } + "about" | "open-about" | "open about" => { + self.open_tab(about_url()?); + Ok(true) + } "settings" | "open-settings" | "open settings" => { self.open_tab(settings_url()?); Ok(true) diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 8708e8a..405f024 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -81,6 +81,21 @@ fn open_history_command_opens_history_page() -> Result<(), Box> { Ok(()) } +#[test] +fn open_about_command_opens_about_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">about"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("about".to_string()))); + assert_eq!(active_tab.title(), "About ELY Browser"); + assert_eq!(active_tab.url().as_str(), "ely://about"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn open_settings_command_opens_settings_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -111,6 +126,27 @@ fn open_sync_status_command_opens_sync_status_page() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings about"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "about".to_string() + }) + ); + assert_eq!(active_tab.title(), "About ELY Browser"); + assert_eq!(active_tab.url().as_str(), "ely://about"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn settings_scoped_search_opens_matching_settings_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;