From f93943ccfe75b9fdaa47298b967da44a779d99e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 7 May 2026 21:30:39 -0400 Subject: [PATCH] Render downloads internal page --- crates/ely_app/src/shell/internal_pages.rs | 3 + .../src/shell/internal_pages/downloads.rs | 183 ++++++++++++++++++ crates/ely_browser_core/src/state.rs | 9 +- .../ely_browser_core/src/state/downloads.rs | 35 ++++ crates/ely_browser_core/tests/downloads.rs | 38 ++++ crates/ely_domain/src/download.rs | 91 +++++++++ crates/ely_domain/src/identifiers.rs | 1 + crates/ely_domain/src/lib.rs | 4 +- 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/downloads.rs create mode 100644 crates/ely_browser_core/src/state/downloads.rs create mode 100644 crates/ely_browser_core/tests/downloads.rs create mode 100644 crates/ely_domain/src/download.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index da5e512..b06a710 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,3 +1,5 @@ +mod downloads; + use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab, HistoryEntry}; @@ -17,6 +19,7 @@ impl ElyShell { cx: &mut Context, ) -> AnyElement { match tab.url().as_str() { + "ely://downloads" => self.render_downloads_page(snapshot), "ely://history" => self.render_history_page(snapshot, cx), "ely://archive" => self.render_archive_page(snapshot, cx), _ => render_default_page(tab), diff --git a/crates/ely_app/src/shell/internal_pages/downloads.rs b/crates/ely_app/src/shell/internal_pages/downloads.rs new file mode 100644 index 0000000..4f6fe4a --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/downloads.rs @@ -0,0 +1,183 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{DownloadEntry, DownloadState}; +use gpui::{ + AnyElement, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px, rgb, +}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_downloads_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child( + div() + .flex() + .items_end() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child("Downloads"), + ) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(snapshot.active_profile_name.clone()), + ), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} downloads", snapshot.download_entries.len())), + ), + ) + .child(render_downloads_list(snapshot)), + ) + } +} + +fn render_downloads_list(snapshot: &BrowserSnapshot) -> AnyElement { + if snapshot.download_entries.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Downloads are empty for this Profile.") + .into_any_element(); + } + + div() + .flex_1() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + snapshot + .download_entries + .iter() + .rev() + .enumerate() + .map(|(index, entry)| render_download_row(index, entry)), + ) + .into_any_element() +} + +fn render_download_row(index: usize, entry: &DownloadEntry) -> AnyElement { + div() + .id(SharedString::from(format!("download-{index}"))) + .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)).child(IconName::File)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(entry.file_name().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(entry.source_url().display_url()), + ), + ), + ) + .child( + div() + .flex() + .flex_col() + .items_end() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::BODY)) + .child(download_state_label(entry.state())), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(download_size_label(entry)), + ), + ) + .into_any_element() +} + +fn download_state_label(state: &DownloadState) -> &'static str { + match state { + DownloadState::InProgress => "In progress", + DownloadState::Paused => "Paused", + DownloadState::Completed => "Complete", + DownloadState::Cancelled => "Cancelled", + DownloadState::Failed => "Failed", + } +} + +fn download_size_label(entry: &DownloadEntry) -> String { + match entry.total_bytes() { + Some(total_bytes) => { + format!("{} of {}", format_bytes(entry.received_bytes()), format_bytes(total_bytes)) + } + None => format_bytes(entry.received_bytes()), + } +} + +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + + if bytes >= GIB { + return format!("{:.1} GB", bytes as f64 / GIB as f64); + } + if bytes >= MIB { + return format!("{:.1} MB", bytes as f64 / MIB as f64); + } + if bytes >= KIB { + return format!("{:.1} KB", bytes as f64 / KIB as f64); + } + format!("{bytes} B") +} diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 803b1c4..fa1ff32 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,13 +1,14 @@ use std::collections::BTreeMap; use ely_domain::{ - ArchivedTab, BrowserTab, DomainError, HistoryEntry, Profile, ProfileId, ProfileKind, Space, - SpaceId, TabId, UrlText, + ArchivedTab, BrowserTab, DomainError, DownloadEntry, HistoryEntry, Profile, ProfileId, + ProfileKind, Space, SpaceId, TabId, UrlText, }; use crate::CoreError; mod commands; +mod downloads; mod history; mod profiles; mod tabs; @@ -37,6 +38,7 @@ pub struct BrowserSnapshot { pub favorites: Vec, pub pinned_tabs: Vec, pub archived_tabs: Vec, + pub download_entries: Vec, pub history_entries: Vec, pub spaces: Vec, pub active_tab_id: TabId, @@ -52,6 +54,7 @@ pub struct BrowserCore { profiles: Vec, tabs: Vec, archived_tabs: Vec, + download_entries: Vec, history_entries: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, @@ -93,6 +96,7 @@ impl BrowserCore { profiles: vec![profile], tabs: vec![tab], archived_tabs: Vec::new(), + download_entries: Vec::new(), history_entries: Vec::new(), command_query: String::new(), new_tab_url, @@ -179,6 +183,7 @@ impl BrowserCore { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_tabs.clone(), + download_entries: self.visible_downloads(), history_entries: self.visible_history(), spaces: self.spaces.clone(), tabs: self.visible_tabs(), diff --git a/crates/ely_browser_core/src/state/downloads.rs b/crates/ely_browser_core/src/state/downloads.rs new file mode 100644 index 0000000..956317c --- /dev/null +++ b/crates/ely_browser_core/src/state/downloads.rs @@ -0,0 +1,35 @@ +use std::time::SystemTime; + +use ely_domain::{DownloadEntry, DownloadId, UrlText}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn record_download_started( + &mut self, + source_url: UrlText, + file_name: impl Into, + total_bytes: Option, + ) -> Result { + let entry = DownloadEntry::started( + self.active_profile_id.clone(), + source_url, + file_name, + total_bytes, + SystemTime::now(), + )?; + let download_id = entry.id().clone(); + self.download_entries.push(entry); + Ok(download_id) + } + + pub(super) fn visible_downloads(&self) -> Vec { + self.download_entries + .iter() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } +} diff --git a/crates/ely_browser_core/tests/downloads.rs b/crates/ely_browser_core/tests/downloads.rs new file mode 100644 index 0000000..a9eb147 --- /dev/null +++ b/crates/ely_browser_core/tests/downloads.rs @@ -0,0 +1,38 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ProfileKind, UrlText}; + +#[test] +fn download_entries_stay_with_active_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.active_tab()?.profile_id().clone(); + + core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + let default_snapshot = core.snapshot()?; + assert_eq!(default_snapshot.download_entries.len(), 1); + assert_eq!(default_snapshot.download_entries[0].file_name(), "report.pdf"); + assert_eq!(default_snapshot.download_entries[0].profile_id(), &default_profile_id); + + core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + let personal_snapshot = core.snapshot()?; + assert!(personal_snapshot.download_entries.is_empty()); + + core.record_download_started( + UrlText::parse("https://example.com/archive.zip")?, + "archive.zip", + None, + )?; + assert_eq!(core.snapshot()?.download_entries.len(), 1); + + core.select_profile(&default_profile_id)?; + let default_snapshot = core.snapshot()?; + assert_eq!(default_snapshot.download_entries.len(), 1); + assert_eq!(default_snapshot.download_entries[0].file_name(), "report.pdf"); + Ok(()) +} diff --git a/crates/ely_domain/src/download.rs b/crates/ely_domain/src/download.rs new file mode 100644 index 0000000..f9ff7b0 --- /dev/null +++ b/crates/ely_domain/src/download.rs @@ -0,0 +1,91 @@ +use std::time::SystemTime; + +use crate::{DomainError, DownloadId, ProfileId, UrlText}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DownloadState { + InProgress, + Paused, + Completed, + Cancelled, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DownloadEntry { + id: DownloadId, + profile_id: ProfileId, + source_url: UrlText, + file_name: String, + state: DownloadState, + received_bytes: u64, + total_bytes: Option, + started_at: SystemTime, +} + +impl DownloadEntry { + pub fn started( + profile_id: ProfileId, + source_url: UrlText, + file_name: impl Into, + total_bytes: Option, + started_at: SystemTime, + ) -> Result { + let file_name = file_name.into(); + let file_name = file_name.trim(); + if file_name.is_empty() { + return Err(DomainError::EmptyField { field: "file_name" }); + } + + Ok(Self { + id: DownloadId::new(), + profile_id, + source_url, + file_name: file_name.to_string(), + state: DownloadState::InProgress, + received_bytes: 0, + total_bytes, + started_at, + }) + } + + #[must_use] + pub fn id(&self) -> &DownloadId { + &self.id + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub fn source_url(&self) -> &UrlText { + &self.source_url + } + + #[must_use] + pub fn file_name(&self) -> &str { + &self.file_name + } + + #[must_use] + pub fn state(&self) -> &DownloadState { + &self.state + } + + #[must_use] + pub fn received_bytes(&self) -> u64 { + self.received_bytes + } + + #[must_use] + pub fn total_bytes(&self) -> Option { + self.total_bytes + } + + #[must_use] + pub fn started_at(&self) -> SystemTime { + self.started_at + } +} diff --git a/crates/ely_domain/src/identifiers.rs b/crates/ely_domain/src/identifiers.rs index cd69a0c..9d84395 100644 --- a/crates/ely_domain/src/identifiers.rs +++ b/crates/ely_domain/src/identifiers.rs @@ -38,3 +38,4 @@ entity_id!(SpaceId, "space"); entity_id!(ProfileId, "profile"); entity_id!(SplitId, "split"); entity_id!(WebViewId, "webview"); +entity_id!(DownloadId, "download"); diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 63e5766..b20f636 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -1,5 +1,6 @@ mod archive; mod command; +mod download; mod error; mod history; mod identifiers; @@ -11,9 +12,10 @@ mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; pub use command::{CommandIntent, CommandScope}; +pub use download::{DownloadEntry, DownloadState}; pub use error::DomainError; pub use history::HistoryEntry; -pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId}; +pub use identifiers::{DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId}; pub use profile::{Profile, ProfileKind}; pub use space::{ArchivePolicy, Space}; pub use split::{SplitAxis, SplitLayout, SplitPane};