diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index df92919..cbc76c6 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -23,6 +23,7 @@ actions!( RestoreClosedTab, SelectNextTab, SelectPreviousTab, + SplitRight, ToggleFavoriteTab, TogglePinnedTab, ] @@ -35,6 +36,8 @@ fn main() { cx.bind_keys([ KeyBinding::new("cmd-t", OpenNewTab, None), KeyBinding::new("ctrl-t", OpenNewTab, None), + KeyBinding::new("cmd-\\", SplitRight, None), + KeyBinding::new("ctrl-\\", SplitRight, None), KeyBinding::new("cmd-shift-j", OpenDownloads, None), KeyBinding::new("ctrl-shift-j", OpenDownloads, None), KeyBinding::new("cmd-y", OpenHistory, None), @@ -72,6 +75,7 @@ fn main() { name: "File".into(), items: vec![ MenuItem::action("New Tab", OpenNewTab), + MenuItem::action("Split Right", SplitRight), MenuItem::separator(), MenuItem::action("Command Mode", FocusCommandMode), MenuItem::separator(), diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 56e4415..51c9f21 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -3,6 +3,7 @@ mod internal_pages; mod plugins; mod render; mod site_permissions; +mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{CommandIntent, ProfileId, SpaceId, TabId, UrlText}; diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index b2b3051..169fde9 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -46,6 +46,7 @@ impl ElyShell { .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)) + .on_action(cx.listener(Self::on_split_right)) .on_action(cx.listener(Self::on_toggle_favorite_tab)) .on_action(cx.listener(Self::on_toggle_pinned_tab)) .bg(rgb(ELY_THEME.canvas)) @@ -59,7 +60,7 @@ impl ElyShell { .flex_1() .overflow_hidden() .child(self.render_sidebar(&snapshot, cx)) - .child(self.render_web_canvas(&active_tab, &snapshot, cx)), + .child(self.render_content_area(&snapshot, &active_tab, cx)), ) .into_any_element() } diff --git a/crates/ely_app/src/shell/splits.rs b/crates/ely_app/src/shell/splits.rs new file mode 100644 index 0000000..bf345d0 --- /dev/null +++ b/crates/ely_app/src/shell/splits.rs @@ -0,0 +1,167 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::{colors, spacing}; +use ely_domain::{BrowserTab, SplitAxis, SplitLayout}; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, rgb, +}; +use gpui_component::{IconName, StyledExt}; + +use super::{ElyShell, ShellState}; +use crate::SplitRight; + +impl ElyShell { + pub(super) fn render_content_area( + &mut self, + snapshot: &BrowserSnapshot, + active_tab: &BrowserTab, + cx: &mut Context, + ) -> AnyElement { + let Some(layout) = active_split_layout(snapshot, active_tab) else { + return self.render_web_canvas(active_tab, snapshot, cx); + }; + + if layout.pane_count() < 2 { + return self.render_web_canvas(active_tab, snapshot, cx); + } + + self.render_split_canvas(snapshot, active_tab, layout, cx) + } + + pub(super) fn split_right(&mut self, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.split_active_tab_right().is_ok() + { + self.sync_address_input(window, cx); + self.focus_address_bar(window, cx); + cx.notify(); + } + } + + pub(super) fn on_split_right( + &mut self, + _: &SplitRight, + window: &mut Window, + cx: &mut Context, + ) { + self.split_right(window, cx); + } + + fn render_split_canvas( + &mut self, + snapshot: &BrowserSnapshot, + active_tab: &BrowserTab, + layout: &SplitLayout, + cx: &mut Context, + ) -> AnyElement { + let panes = layout + .panes() + .iter() + .filter_map(|pane| snapshot.tabs.iter().find(|tab| tab.id() == pane.tab_id())) + .collect::>(); + + if panes.len() < 2 { + return self.render_web_canvas(active_tab, snapshot, cx); + } + + let body = div() + .flex_1() + .h_full() + .min_w_0() + .p_3() + .gap_3() + .overflow_hidden() + .bg(rgb(colors::CANVAS)); + + let body = match layout.axis() { + SplitAxis::Vertical => body.flex().flex_col(), + SplitAxis::Horizontal | SplitAxis::Grid => body.flex(), + }; + + body.children( + panes + .into_iter() + .enumerate() + .map(|(index, tab)| self.render_split_pane(index, tab, snapshot, cx)), + ) + .into_any_element() + } + + fn render_split_pane( + &mut self, + index: usize, + tab: &BrowserTab, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + let active = tab.id() == &snapshot.active_tab_id; + let tab_id = tab.id().clone(); + let pane_number = index + 1; + let border = if active { colors::PRIMARY } else { colors::HAIRLINE_STRONG }; + let title_color = if active { colors::INK } else { colors::BODY }; + + div() + .id(SharedString::from(format!("split-pane-{}", tab.id().as_str()))) + .flex_1() + .h_full() + .min_w(px(240.0)) + .flex() + .flex_col() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(rgb(border)) + .bg(rgb(colors::SURFACE_CARD)) + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::CANVAS_SOFT))) + .active(|style| style.opacity(0.92)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_tab(&tab_id, window, cx); + })) + .child( + div() + .h(px(spacing::COMMAND_BAR_HEIGHT - spacing::MD)) + .px_3() + .gap_2() + .flex() + .items_center() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .child(div().text_color(rgb(colors::MUTED)).child(IconName::Frame)) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(format!("Pane {pane_number}")), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .font_semibold() + .text_color(rgb(title_color)) + .child(tab.title().to_string()), + ), + ) + .child( + div() + .flex_1() + .min_h_0() + .overflow_hidden() + .child(self.render_web_canvas(tab, snapshot, cx)), + ) + .into_any_element() + } +} + +fn active_split_layout<'a>( + snapshot: &'a BrowserSnapshot, + active_tab: &BrowserTab, +) -> Option<&'a SplitLayout> { + let split_id = active_tab.split_id()?; + snapshot.split_layouts.iter().find(|layout| layout.id() == split_id) +} diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index d989a26..1861c64 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -1,4 +1,4 @@ -use ely_domain::{DomainError, DownloadId, PluginId, ProfileId, SpaceId, TabId}; +use ely_domain::{DomainError, DownloadId, PluginId, ProfileId, SpaceId, SplitId, TabId}; use thiserror::Error; #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -12,6 +12,9 @@ pub enum CoreError { #[error("space not found: {id}")] SpaceNotFound { id: SpaceId }, + #[error("split not found: {id}")] + SplitNotFound { id: SplitId }, + #[error("profile not found: {id}")] ProfileNotFound { id: ProfileId }, @@ -33,6 +36,9 @@ pub enum CoreError { #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, + #[error("split pane limit reached: {limit}")] + SplitPaneLimitReached { limit: usize }, + #[error("browser state has no archived tabs")] NoArchivedTabs, diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index e78999b..e70af1e 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use ely_domain::{ ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SitePermissionAuditEvent, - SitePermissionEntry, Space, SpaceId, SyncStatus, TabId, UrlText, + SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, }; use crate::CoreError; @@ -16,6 +16,7 @@ mod plugins; mod profiles; mod reading_list; mod site_permissions; +mod splits; mod sync; mod tabs; @@ -52,6 +53,7 @@ pub struct BrowserSnapshot { pub site_permission_audit_events: Vec, pub download_entries: Vec, pub history_entries: Vec, + pub split_layouts: Vec, pub installed_plugins: Vec, pub plugin_audit_events: Vec, pub spaces: Vec, @@ -78,6 +80,7 @@ pub struct BrowserCore { site_permission_audit_events: Vec, download_entries: Vec, history_entries: Vec, + split_layouts: Vec, installed_plugins: Vec, plugin_audit_events: Vec, active_space_id: SpaceId, @@ -126,6 +129,7 @@ impl BrowserCore { site_permission_audit_events: Vec::new(), download_entries: Vec::new(), history_entries: Vec::new(), + split_layouts: Vec::new(), installed_plugins: Vec::new(), plugin_audit_events: Vec::new(), command_query: String::new(), @@ -211,6 +215,7 @@ impl BrowserCore { site_permission_audit_events: self.visible_site_permission_audit_events(), download_entries: self.visible_downloads(), history_entries: self.visible_history(), + split_layouts: self.visible_split_layouts(), installed_plugins: self.installed_plugins.clone(), plugin_audit_events: self.plugin_audit_events.clone(), spaces: self.spaces.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 2f1b155..53f8882 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -121,6 +121,10 @@ impl BrowserCore { self.open_tab(self.new_tab_url.clone()); Ok(true) } + "split-right" | "split right" => { + self.split_active_tab_right()?; + Ok(true) + } "downloads" | "open-downloads" | "open downloads" => { self.open_tab(downloads_url()?); Ok(true) diff --git a/crates/ely_browser_core/src/state/splits.rs b/crates/ely_browser_core/src/state/splits.rs new file mode 100644 index 0000000..d14e815 --- /dev/null +++ b/crates/ely_browser_core/src/state/splits.rs @@ -0,0 +1,101 @@ +use ely_domain::{MAX_SPLIT_PANES, SplitAxis, SplitId, SplitLayout, SplitPane, TabId}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn split_active_tab_right(&mut self) -> Result { + let active_index = self.active_tab_index()?; + let active_tab_id = self.tabs[active_index].id().clone(); + let active_space_id = self.tabs[active_index].space_id().clone(); + let active_profile_id = self.tabs[active_index].profile_id().clone(); + let split_id = self.split_id_for_new_pane(&active_tab_id)?; + let mut new_tab = + self.build_tab_for(active_space_id, active_profile_id, self.new_tab_url.clone()); + let new_tab_id = new_tab.id().clone(); + new_tab.set_split_id(split_id.clone()); + + let layout = self + .split_layouts + .iter_mut() + .find(|layout| layout.id() == &split_id) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + if !layout.add_pane(SplitPane::new(new_tab_id.clone(), 1)) { + return Err(CoreError::SplitPaneLimitReached { limit: MAX_SPLIT_PANES }); + } + + let insert_index = self.active_tab_index()? + 1; + self.tabs.insert(insert_index, new_tab); + self.select_tab(&new_tab_id)?; + Ok(split_id) + } + + pub(super) fn detach_tab_from_split(&mut self, tab_id: &TabId) { + for tab in self.tabs.iter_mut().filter(|tab| tab.id() == tab_id) { + tab.clear_split_id(); + } + + for layout in &mut self.split_layouts { + layout.remove_tab(tab_id); + } + + let dissolved_split_ids = self + .split_layouts + .iter() + .filter(|layout| layout.pane_count() <= 1) + .map(|layout| layout.id().clone()) + .collect::>(); + + for split_id in &dissolved_split_ids { + for tab in self.tabs.iter_mut().filter(|tab| tab.split_id() == Some(split_id)) { + tab.clear_split_id(); + } + } + + self.split_layouts.retain(|layout| layout.pane_count() > 1); + } + + pub(super) fn visible_split_layouts(&self) -> Vec { + self.split_layouts + .iter() + .filter(|layout| { + layout.panes().iter().all(|pane| { + self.tabs.iter().any(|tab| { + tab.id() == pane.tab_id() && tab.space_id() == &self.active_space_id + }) + }) + }) + .cloned() + .collect() + } + + fn split_id_for_new_pane(&mut self, active_tab_id: &TabId) -> Result { + if let Some(split_id) = self.active_split_id(active_tab_id) { + if self + .split_layouts + .iter() + .any(|layout| layout.id() == &split_id && layout.pane_count() < MAX_SPLIT_PANES) + { + return Ok(split_id); + } + + self.detach_tab_from_split(active_tab_id); + } + + let active_index = self.active_tab_index()?; + let layout = + SplitLayout::new(SplitAxis::Horizontal, vec![SplitPane::new(active_tab_id.clone(), 1)]); + let split_id = layout.id().clone(); + self.tabs[active_index].set_split_id(split_id.clone()); + self.split_layouts.push(layout); + Ok(split_id) + } + + fn active_split_id(&self, active_tab_id: &TabId) -> Option { + self.tabs + .iter() + .find(|tab| tab.id() == active_tab_id) + .and_then(|tab| tab.split_id().cloned()) + } +} diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index d72468e..0850b5c 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -40,6 +40,8 @@ impl BrowserCore { return Ok(tab_id); } + self.detach_tab_from_split(&tab_id); + let tab_index = self.active_tab_index()?; self.tabs[tab_index].move_to_space(space_id.clone()); self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone()); self.active_tabs_by_space_profile.remove(&(source_space_id.clone(), profile_id.clone())); @@ -82,10 +84,12 @@ impl BrowserCore { .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; let was_active = &self.active_tab_id == tab_id; - let closed_tab = self.tabs.remove(close_index); + let mut closed_tab = self.tabs.remove(close_index); let closed_space_id = closed_tab.space_id().clone(); let closed_profile_id = closed_tab.profile_id().clone(); let was_space_active_tab = self.active_tabs_by_space.get(&closed_space_id) == Some(tab_id); + closed_tab.clear_split_id(); + self.detach_tab_from_split(tab_id); self.active_tabs_by_space_profile .remove(&(closed_space_id.clone(), closed_profile_id.clone())); self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose)); @@ -270,7 +274,7 @@ impl BrowserCore { Ok(next_tab_id) } - fn active_tab_index(&self) -> Result { + pub(super) fn active_tab_index(&self) -> Result { self.tabs .iter() .position(|tab| tab.id() == &self.active_tab_id) diff --git a/crates/ely_browser_core/tests/splits.rs b/crates/ely_browser_core/tests/splits.rs new file mode 100644 index 0000000..00c7599 --- /dev/null +++ b/crates/ely_browser_core/tests/splits.rs @@ -0,0 +1,85 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, SplitAxis}; + +#[test] +fn split_right_creates_two_pane_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + + let split_id = core.split_active_tab_right()?; + let snapshot = core.snapshot()?; + let layout = snapshot + .split_layouts + .iter() + .find(|layout| layout.id() == &split_id) + .ok_or("missing split layout")?; + + assert_eq!(snapshot.tabs.len(), 2); + assert_eq!(layout.axis(), &SplitAxis::Horizontal); + assert_eq!(layout.pane_count(), 2); + assert_eq!(layout.panes()[0].tab_id(), &first_tab_id); + assert_eq!(layout.panes()[1].tab_id(), &snapshot.active_tab_id); + assert!(snapshot.tabs.iter().all(|tab| tab.split_id() == Some(&split_id))); + Ok(()) +} + +#[test] +fn split_right_command_focuses_new_pane() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">split-right"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("split-right".to_string()))); + assert_eq!(snapshot.tabs.len(), 2); + assert_eq!(snapshot.split_layouts.len(), 1); + assert_eq!(active_tab.url().as_str(), "ely://new-tab"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn closing_split_pane_dissolves_two_pane_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let remaining_tab_id = core.active_tab()?.id().clone(); + core.split_active_tab_right()?; + + core.close_active_tab()?; + let snapshot = core.snapshot()?; + + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.active_tab_id, remaining_tab_id); + assert_eq!(snapshot.tabs[0].split_id(), None); + assert_eq!(snapshot.archived_tabs.len(), 1); + assert_eq!(snapshot.archived_tabs[0].tab().split_id(), None); + Ok(()) +} + +#[test] +fn moving_split_pane_dissolves_source_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let work_space_id = core.snapshot()?.active_space_id; + let moved_tab_id = { + let research_space_id = core.create_space("Research", "R", 0xf54e00)?; + core.select_space(&work_space_id)?; + core.split_active_tab_right()?; + let moved_tab_id = core.active_tab()?.id().clone(); + core.move_active_tab_to_space(&research_space_id)?; + moved_tab_id + }; + + let research_snapshot = core.snapshot()?; + assert_eq!(research_snapshot.active_tab_id, moved_tab_id); + assert!(research_snapshot.split_layouts.is_empty()); + + core.select_space(&work_space_id)?; + let work_snapshot = core.snapshot()?; + assert!(work_snapshot.split_layouts.is_empty()); + assert!(work_snapshot.tabs.iter().all(|tab| tab.split_id().is_none())); + Ok(()) +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 17a0a49..cc7e807 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -38,7 +38,7 @@ pub use site_permission::{ SitePermissionEntry, SitePermissionFeature, }; pub use space::{ArchivePolicy, Space}; -pub use split::{SplitAxis, SplitLayout, SplitPane}; +pub use split::{MAX_SPLIT_PANES, SplitAxis, SplitLayout, SplitPane}; pub use sync::{ SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus, SyncStatus, }; diff --git a/crates/ely_domain/src/split.rs b/crates/ely_domain/src/split.rs index 7923137..9e77911 100644 --- a/crates/ely_domain/src/split.rs +++ b/crates/ely_domain/src/split.rs @@ -1,5 +1,7 @@ use crate::{SplitId, TabId}; +pub const MAX_SPLIT_PANES: usize = 4; + #[derive(Clone, Debug, Eq, PartialEq)] pub enum SplitAxis { Horizontal, @@ -57,4 +59,29 @@ impl SplitLayout { pub fn panes(&self) -> &[SplitPane] { &self.panes } + + #[must_use] + pub fn contains_tab(&self, tab_id: &TabId) -> bool { + self.panes.iter().any(|pane| pane.tab_id() == tab_id) + } + + #[must_use] + pub fn pane_count(&self) -> usize { + self.panes.len() + } + + pub fn add_pane(&mut self, pane: SplitPane) -> bool { + if self.panes.len() >= MAX_SPLIT_PANES || self.contains_tab(pane.tab_id()) { + return false; + } + + self.panes.push(pane); + true + } + + pub fn remove_tab(&mut self, tab_id: &TabId) -> bool { + let original_len = self.panes.len(); + self.panes.retain(|pane| pane.tab_id() != tab_id); + self.panes.len() != original_len + } } diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index bd62b5a..4818de9 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -114,4 +114,12 @@ impl BrowserTab { pub fn split_id(&self) -> Option<&SplitId> { self.split_id.as_ref() } + + pub fn set_split_id(&mut self, split_id: SplitId) { + self.split_id = Some(split_id); + } + + pub fn clear_split_id(&mut self) { + self.split_id = None; + } }