diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 8e971a6..df92919 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -18,6 +18,7 @@ actions!( OpenHistory, OpenNewTab, OpenSettings, + OpenTaskManager, Quit, RestoreClosedTab, SelectNextTab, @@ -38,6 +39,8 @@ fn main() { KeyBinding::new("ctrl-shift-j", OpenDownloads, None), KeyBinding::new("cmd-y", OpenHistory, None), KeyBinding::new("ctrl-h", OpenHistory, None), + KeyBinding::new("cmd-escape", OpenTaskManager, None), + KeyBinding::new("shift-escape", OpenTaskManager, None), KeyBinding::new("cmd-,", OpenSettings, None), KeyBinding::new("ctrl-,", OpenSettings, None), KeyBinding::new("cmd-l", FocusAddressBar, None), @@ -78,6 +81,7 @@ fn main() { MenuItem::separator(), MenuItem::action("Open Downloads", OpenDownloads), MenuItem::action("Open History", OpenHistory), + MenuItem::action("Open Task Manager", OpenTaskManager), MenuItem::action("Open Settings", OpenSettings), MenuItem::separator(), MenuItem::action("Toggle Pin", TogglePinnedTab), diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 9f7d907..768421a 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -7,6 +7,7 @@ mod plugins; mod profiles; mod reading_list; mod sync; +mod task_manager; use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; @@ -32,6 +33,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://task-manager" => self.render_task_manager_page(snapshot), "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), diff --git a/crates/ely_app/src/shell/internal_pages/task_manager.rs b/crates/ely_app/src/shell/internal_pages/task_manager.rs new file mode 100644 index 0000000..fdf29bd --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/task_manager.rs @@ -0,0 +1,285 @@ +use ely_browser_core::{BrowserSnapshot, InstalledPlugin}; +use ely_design_system::colors; +use ely_domain::{BrowserTab, DownloadEntry, DownloadState, TabId, TabState}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px, rgb, +}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ + ElyShell, + download_labels::{download_size_label, download_state_label}, + render_canvas_surface, +}; + +impl ElyShell { + pub(super) fn render_task_manager_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_task_manager_header(snapshot)) + .child(render_task_summary(snapshot)) + .child(render_task_table(snapshot)), + ) + } +} + +fn render_task_manager_header(snapshot: &BrowserSnapshot) -> 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("Task Manager")) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(format!( + "{} / {}", + snapshot.active_profile_name, snapshot.active_space_name + ))), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::LayoutDashboard) + .child(format!("{} local tasks", task_count(snapshot))), + ) + .into_any_element() +} + +fn render_task_summary(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .children([ + task_metric("Tabs", snapshot.tabs.len()), + task_metric("Downloads", snapshot.download_entries.len()), + task_metric("Plugins", snapshot.installed_plugins.len()), + task_metric("Spaces", snapshot.spaces.len()), + ]) + .into_any_element() +} + +fn task_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() +} + +fn render_task_table(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .child(render_section_header("Tabs")) + .children( + snapshot + .tabs + .iter() + .enumerate() + .map(|(index, tab)| render_tab_task_row(index, tab, &snapshot.active_tab_id)), + ) + .when(!snapshot.download_entries.is_empty(), |this| { + this.child(render_section_header("Downloads")).children( + snapshot + .download_entries + .iter() + .rev() + .enumerate() + .map(|(index, entry)| render_download_task_row(index, entry)), + ) + }) + .when(!snapshot.installed_plugins.is_empty(), |this| { + this.child(render_section_header("Plugins")).children( + snapshot + .installed_plugins + .iter() + .enumerate() + .map(|(index, plugin)| render_plugin_task_row(index, plugin)), + ) + }) + .into_any_element() +} + +fn render_section_header(label: &'static str) -> AnyElement { + div() + .pt_4() + .pb_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(label) + .into_any_element() +} + +fn render_tab_task_row(index: usize, tab: &BrowserTab, active_tab_id: &TabId) -> AnyElement { + let status = tab_state_label(tab.state()); + let status_color = tab_state_color(tab.state()); + let scope = if tab.id() == active_tab_id { "Active tab" } else { "Tab" }; + let icon = if tab.id() == active_tab_id { IconName::CircleCheck } else { IconName::Globe }; + + render_task_row( + format!("tab-task-{index}"), + icon, + tab.title().to_string(), + tab.display_url(), + scope, + status, + status_color, + ) +} + +fn render_download_task_row(index: usize, entry: &DownloadEntry) -> AnyElement { + render_task_row( + format!("download-task-{index}"), + IconName::File, + entry.file_name().to_string(), + download_size_label(entry), + "Download", + download_state_label(entry.state()), + download_state_color(entry.state()), + ) +} + +fn render_plugin_task_row(index: usize, plugin: &InstalledPlugin) -> AnyElement { + let status = if plugin.enabled() { "Enabled" } else { "Disabled" }; + let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED }; + let detail = format!( + "{} permissions - {} contributions", + plugin.manifest().permissions().len(), + plugin.manifest().contributes().len() + ); + + render_task_row( + format!("plugin-task-{index}"), + IconName::Asterisk, + plugin.manifest().name().to_string(), + detail, + "Plugin", + status, + status_color, + ) +} + +fn render_task_row( + id: String, + icon: IconName, + title: String, + detail: String, + scope: &'static str, + status: &'static str, + status_color: u32, +) -> AnyElement { + div() + .id(SharedString::from(id)) + .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(title), + ) + .child( + div().text_xs().truncate().text_color(rgb(colors::MUTED)).child(detail), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_3() + .text_xs() + .child(div().min_w(px(86.0)).text_color(rgb(colors::MUTED)).child(scope)) + .child( + div() + .min_w(px(76.0)) + .font_semibold() + .text_color(rgb(status_color)) + .child(status), + ), + ) + .into_any_element() +} + +fn task_count(snapshot: &BrowserSnapshot) -> usize { + snapshot.tabs.len() + snapshot.download_entries.len() + snapshot.installed_plugins.len() +} + +fn tab_state_label(state: &TabState) -> &'static str { + match state { + TabState::Loading => "Loading", + TabState::Ready => "Ready", + TabState::Crashed => "Crashed", + TabState::Discarded => "Discarded", + TabState::Archived => "Archived", + } +} + +fn tab_state_color(state: &TabState) -> u32 { + match state { + TabState::Loading => colors::PRIMARY, + TabState::Ready => colors::SUCCESS, + TabState::Crashed => colors::ERROR, + TabState::Discarded | TabState::Archived => colors::MUTED, + } +} + +fn download_state_color(state: &DownloadState) -> u32 { + match state { + DownloadState::InProgress => colors::PRIMARY, + DownloadState::Completed => colors::SUCCESS, + DownloadState::Paused => colors::MUTED, + DownloadState::Cancelled | DownloadState::Failed => colors::ERROR, + } +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 7fc5ee0..a7aae6c 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -13,8 +13,8 @@ use plugins::{PendingPluginInstall, PendingPluginUninstall}; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, - OpenSettings, RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, - TogglePinnedTab, + OpenSettings, OpenTaskManager, RestoreClosedTab, SelectNextTab, SelectPreviousTab, + ToggleFavoriteTab, TogglePinnedTab, }; enum ShellState { @@ -114,6 +114,10 @@ impl ElyShell { self.open_internal_tab("ely://settings", window, cx); } + fn open_task_manager(&mut self, window: &mut Window, cx: &mut Context) { + self.open_internal_tab("ely://task-manager", window, cx); + } + fn open_internal_tab(&mut self, url_text: &str, window: &mut Window, cx: &mut Context) { if let Ok(url) = UrlText::parse(url_text) { self.open_url(url, window, cx); @@ -294,6 +298,15 @@ impl ElyShell { self.open_settings(window, cx); } + fn on_open_task_manager( + &mut self, + _: &OpenTaskManager, + window: &mut Window, + cx: &mut Context, + ) { + self.open_task_manager(window, cx); + } + fn on_restore_closed_tab( &mut self, _: &RestoreClosedTab, diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 83b02b2..b2b3051 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -42,6 +42,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_open_history)) .on_action(cx.listener(Self::on_open_new_tab)) .on_action(cx.listener(Self::on_open_settings)) + .on_action(cx.listener(Self::on_open_task_manager)) .on_action(cx.listener(Self::on_restore_closed_tab)) .on_action(cx.listener(Self::on_select_next_tab)) .on_action(cx.listener(Self::on_select_previous_tab)) diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 0eecf86..e978b1c 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -21,6 +21,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://downloads" => Some("Downloads"), "ely://history" => Some("History"), "ely://archive" => Some("Archived Tabs"), + "ely://task-manager" => Some("Task Manager"), "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/plugins" => Some("Plugin Settings"), @@ -98,6 +99,10 @@ pub(crate) fn history_url() -> Result { internal_page_url("ely://history") } +pub(crate) fn task_manager_url() -> Result { + internal_page_url("ely://task-manager") +} + pub(crate) fn about_url() -> Result { internal_page_url("ely://about") } diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 72930e9..fa6fba7 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -5,7 +5,7 @@ use crate::{ navigation::{ about_url, bookmarks_url, downloads_url, history_url, move_tab_space_name, new_profile_name, new_space_name, reading_list_url, search_url, settings_page_url, - settings_url, space_icon, switch_profile_name, sync_status_url, + settings_url, space_icon, switch_profile_name, sync_status_url, task_manager_url, }, }; @@ -126,6 +126,10 @@ impl BrowserCore { self.open_tab(history_url()?); Ok(true) } + "task-manager" | "tasks" | "open-task-manager" | "open task manager" => { + self.open_tab(task_manager_url()?); + Ok(true) + } "about" | "open-about" | "open about" => { self.open_tab(about_url()?); Ok(true) diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 405f024..0052349 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_task_manager_command_opens_task_manager_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-task-manager"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-task-manager".to_string()))); + assert_eq!(active_tab.title(), "Task Manager"); + assert_eq!(active_tab.url().as_str(), "ely://task-manager"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn open_about_command_opens_about_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;