diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index ed3fce3..b3c0c5f 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -2,6 +2,7 @@ mod about; mod bookmarks; mod download_actions; mod download_labels; +mod download_settings; mod downloads; mod general; mod plugin_catalog; @@ -57,6 +58,7 @@ impl ElyShell { "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), "ely://settings/search" => self.render_search_page(snapshot, cx), "ely://settings/privacy-security" => self.render_privacy_security_page(snapshot, cx), + "ely://settings/downloads" => self.render_download_settings_page(snapshot, cx), "ely://settings/spaces" => self.render_spaces_page(snapshot, cx), "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/download_settings.rs b/crates/ely_app/src/shell/internal_pages/download_settings.rs new file mode 100644 index 0000000..d5f3e6d --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/download_settings.rs @@ -0,0 +1,261 @@ +use std::path::{Path, PathBuf}; + +use directories::UserDirs; +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{DownloadDestination, DownloadPolicy}; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Selectable, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, download_labels::download_policy_label, render_canvas_surface}; + +#[derive(Clone)] +struct DownloadPolicyOption { + icon: IconName, + title: &'static str, + detail: String, + policy: DownloadPolicy, +} + +impl ElyShell { + pub(super) fn render_download_settings_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + let options = download_policy_options(); + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_download_settings_header(snapshot)) + .child(render_download_policy_summary(snapshot)) + .child(render_download_policy_rows(&snapshot.active_download_policy, &options, cx)), + ) + } +} + +fn render_download_settings_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Downloads")) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Folder) + .child(download_destination_short_label(&snapshot.active_download_policy)), + ) + .into_any_element() +} + +fn render_download_policy_summary(snapshot: &BrowserSnapshot) -> 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() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(format!("{} entries", snapshot.download_entries.len())), + ) + .into_any_element() +} + +fn render_download_policy_rows( + active_policy: &DownloadPolicy, + options: &[DownloadPolicyOption], + cx: &mut Context, +) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + options.iter().enumerate().map(|(index, option)| { + render_download_policy_row(index, option, active_policy, cx) + }), + ) + .into_any_element() +} + +fn render_download_policy_row( + index: usize, + option: &DownloadPolicyOption, + active_policy: &DownloadPolicy, + cx: &mut Context, +) -> AnyElement { + let selected = option.policy == *active_policy; + let policy = option.policy.clone(); + + 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(download_policy_icon_color(selected))) + .child(download_policy_icon(option, selected)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(option.title), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(option.detail.clone()), + ), + ), + ) + .child( + Button::new(("download-policy", index)) + .ghost() + .xsmall() + .selected(selected) + .label(download_policy_button_label(selected)) + .tooltip(option.title) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_active_profile_download_policy(policy.clone(), cx); + })), + ) + .into_any_element() +} + +fn download_policy_options() -> Vec { + let mut options = vec![DownloadPolicyOption { + icon: IconName::Info, + title: "Ask Every Time", + detail: "Choose a location for each download.".to_string(), + policy: DownloadPolicy::ask_every_time(), + }]; + + if let Some(path) = user_downloads_dir() + && let Ok(policy) = DownloadPolicy::fixed_directory(path.clone()) + { + options.push(DownloadPolicyOption { + icon: IconName::Folder, + title: "Downloads Folder", + detail: path.display().to_string(), + policy, + }); + } + + options +} + +fn user_downloads_dir() -> Option { + UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) +} + +fn download_destination_short_label(policy: &DownloadPolicy) -> &'static str { + match policy.destination() { + DownloadDestination::AskEveryTime => "Ask Every Time", + DownloadDestination::FixedDirectory(_) => "Fixed Folder", + } +} + +fn download_policy_icon(option: &DownloadPolicyOption, selected: bool) -> IconName { + if selected { IconName::CircleCheck } else { option.icon.clone() } +} + +fn download_policy_icon_color(selected: bool) -> u32 { + if selected { colors::PRIMARY } else { colors::MUTED_SOFT } +} + +fn download_policy_button_label(selected: bool) -> &'static str { + if selected { "Active" } else { "Select" } +} diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index fb6c9d0..8bfc213 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -48,6 +48,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "History recording and profile-scoped privacy controls.", route: "ely://settings/privacy-security", }, + SettingsRoute { + icon: IconName::Folder, + title: "Downloads", + detail: "Profile download location and save behavior.", + route: "ely://settings/downloads", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 34645f3..ab107c2 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -7,8 +7,8 @@ mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ - ArchivePolicy, CommandIntent, HistoryRecordingPolicy, NewTabDestination, ProfileId, - SearchEngine, SpaceId, TabId, UrlText, + ArchivePolicy, CommandIntent, DownloadPolicy, HistoryRecordingPolicy, NewTabDestination, + ProfileId, SearchEngine, SpaceId, TabId, UrlText, }; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; @@ -298,6 +298,18 @@ impl ElyShell { } } + fn set_active_profile_download_policy( + &mut self, + policy: DownloadPolicy, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.set_active_profile_download_policy(policy).is_ok() + { + cx.notify(); + } + } + fn archive_idle_tabs_now(&mut self, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state && core.archive_idle_tabs(std::time::SystemTime::now()).is_ok() diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 974f046..b56cf06 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -29,6 +29,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), "ely://settings/search" => Some("Search Settings"), "ely://settings/privacy-security" => Some("Privacy & Security Settings"), + "ely://settings/downloads" => Some("Downloads Settings"), "ely://settings/spaces" => Some("Space Settings"), "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), @@ -167,6 +168,9 @@ fn settings_page_route(query: &str) -> Option<&'static str> { } "privacy" | "security" | "privacy security" | "privacy & security" | "history" | "history recording" => Some("ely://settings/privacy-security"), + "download" | "downloads" | "download settings" | "downloads settings" => { + Some("ely://settings/downloads") + } "space" | "spaces" | "space settings" | "spaces settings" => Some("ely://settings/spaces"), "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { Some("ely://settings/shortcuts") diff --git a/crates/ely_browser_core/src/state/profiles.rs b/crates/ely_browser_core/src/state/profiles.rs index c9aed06..05923f3 100644 --- a/crates/ely_browser_core/src/state/profiles.rs +++ b/crates/ely_browser_core/src/state/profiles.rs @@ -81,4 +81,12 @@ impl BrowserCore { profile.set_download_policy(download_policy); Ok(()) } + + pub fn set_active_profile_download_policy( + &mut self, + download_policy: DownloadPolicy, + ) -> Result<(), CoreError> { + let profile_id = self.active_profile_id.clone(); + self.set_profile_download_policy(&profile_id, download_policy) + } } diff --git a/crates/ely_browser_core/tests/downloads.rs b/crates/ely_browser_core/tests/downloads.rs index aa26ef5..b6f0d99 100644 --- a/crates/ely_browser_core/tests/downloads.rs +++ b/crates/ely_browser_core/tests/downloads.rs @@ -176,6 +176,26 @@ fn records_active_profile_download_policy_on_started_entry() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let policy = DownloadPolicy::fixed_directory("/tmp/ely-active-downloads")?; + + core.set_active_profile_download_policy(policy.clone())?; + core.record_download_started( + UrlText::parse("https://example.com/manual.pdf")?, + "manual.pdf", + Some(1024), + )?; + + let snapshot = core.snapshot()?; + let entry = active_download(&core)?; + assert_eq!(snapshot.active_download_policy, policy); + assert_eq!(entry.destination(), policy.destination()); + assert_eq!(entry.target_file_path(), Some(Path::new("/tmp/ely-active-downloads/manual.pdf"))); + Ok(()) +} + #[test] fn confirms_dangerous_download_security_prompt() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index 9daba13..e0cbf02 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -128,3 +128,24 @@ fn settings_scoped_search_opens_privacy_security_page() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings downloads"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "downloads".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Downloads Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/downloads"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}