diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 169fde9..b737880 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -167,14 +167,7 @@ impl ElyShell { self.render_space_row(space, space.id() == &snapshot.active_space_id, cx) })) .child(section_label("Tabs")) - .children( - snapshot - .tabs - .iter() - .filter(|tab| !tab.flags().favorite) - .filter(|tab| !tab.flags().pinned) - .map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)), - ) + .children(self.render_sidebar_tab_rows(snapshot, cx)) .child(section_label("Archive")) .children( snapshot @@ -324,7 +317,7 @@ impl ElyShell { .into_any_element() } - fn render_tab_row( + pub(super) fn render_tab_row( &mut self, tab: &BrowserTab, active: bool, diff --git a/crates/ely_app/src/shell/splits.rs b/crates/ely_app/src/shell/splits.rs index bf345d0..830fa89 100644 --- a/crates/ely_app/src/shell/splits.rs +++ b/crates/ely_app/src/shell/splits.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeSet; + use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; -use ely_domain::{BrowserTab, SplitAxis, SplitLayout}; +use ely_domain::{BrowserTab, SplitAxis, SplitId, SplitLayout}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, px, rgb, @@ -11,6 +13,37 @@ use super::{ElyShell, ShellState}; use crate::SplitRight; impl ElyShell { + pub(super) fn render_sidebar_tab_rows( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> Vec { + let mut rendered_split_ids = BTreeSet::new(); + let active_split_id = active_split_id(snapshot); + + snapshot + .tabs + .iter() + .filter(|tab| !tab.flags().favorite) + .filter(|tab| !tab.flags().pinned) + .filter_map(|tab| { + let Some(split_id) = tab.split_id() else { + return Some(self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)); + }; + let Some(layout) = saved_split_layout(snapshot, split_id) else { + return Some(self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)); + }; + + if rendered_split_ids.insert(split_id.clone()) { + let active = active_split_id.as_ref() == Some(split_id); + return self.render_saved_split_row(layout, active, cx); + } + + None + }) + .collect() + } + pub(super) fn render_content_area( &mut self, snapshot: &BrowserSnapshot, @@ -156,6 +189,60 @@ impl ElyShell { ) .into_any_element() } + + fn render_saved_split_row( + &mut self, + layout: &SplitLayout, + active: bool, + cx: &mut Context, + ) -> Option { + let first_tab_id = layout.panes().first()?.tab_id().clone(); + let background = if active { colors::SURFACE_CARD } else { colors::CANVAS }; + let border = if active { colors::PRIMARY } else { colors::HAIRLINE }; + + Some( + div() + .id(SharedString::from(format!("saved-split-{}", layout.id().as_str()))) + .rounded_md() + .border_1() + .border_color(rgb(border)) + .bg(rgb(background)) + .px_3() + .py_2() + .gap_2() + .flex() + .items_center() + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::SURFACE_CARD))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_tab(&first_tab_id, window, cx); + })) + .child(div().text_color(rgb(colors::PRIMARY)).child(IconName::Frame)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(layout.title().to_string()), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} panes", layout.pane_count())), + ), + ) + .into_any_element(), + ) + } } fn active_split_layout<'a>( @@ -165,3 +252,18 @@ fn active_split_layout<'a>( let split_id = active_tab.split_id()?; snapshot.split_layouts.iter().find(|layout| layout.id() == split_id) } + +fn active_split_id(snapshot: &BrowserSnapshot) -> Option { + snapshot + .tabs + .iter() + .find(|tab| tab.id() == &snapshot.active_tab_id) + .and_then(|tab| tab.split_id().cloned()) +} + +fn saved_split_layout<'a>( + snapshot: &'a BrowserSnapshot, + split_id: &SplitId, +) -> Option<&'a SplitLayout> { + snapshot.split_layouts.iter().find(|layout| layout.id() == split_id && layout.saved()) +} diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 53f8882..0cc5318 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -125,6 +125,7 @@ impl BrowserCore { self.split_active_tab_right()?; Ok(true) } + "save-split-view" | "save split view" => Ok(self.save_active_split_view()?.is_some()), "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 index d14e815..e6a8eb7 100644 --- a/crates/ely_browser_core/src/state/splits.rs +++ b/crates/ely_browser_core/src/state/splits.rs @@ -5,6 +5,21 @@ use crate::CoreError; use super::BrowserCore; impl BrowserCore { + pub fn save_active_split_view(&mut self) -> Result, CoreError> { + let Some(split_id) = self.active_tab()?.split_id().cloned() else { + return Ok(None); + }; + let title = self.active_split_title(&split_id)?; + let layout = self + .split_layouts + .iter_mut() + .find(|layout| layout.id() == &split_id) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + + layout.save(title); + Ok(Some(split_id)) + } + 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(); @@ -98,4 +113,28 @@ impl BrowserCore { .find(|tab| tab.id() == active_tab_id) .and_then(|tab| tab.split_id().cloned()) } + + fn active_split_title(&self, split_id: &SplitId) -> Result { + let layout = self + .split_layouts + .iter() + .find(|layout| layout.id() == split_id) + .ok_or_else(|| CoreError::SplitNotFound { id: split_id.clone() })?; + let pane_titles = layout + .panes() + .iter() + .filter_map(|pane| { + self.tabs + .iter() + .find(|tab| tab.id() == pane.tab_id()) + .map(|tab| tab.title().to_string()) + }) + .collect::>(); + + Ok(match pane_titles.as_slice() { + [] => "Split View".to_string(), + [title] => format!("Split View: {title}"), + [first, second, ..] => format!("Split View: {first} + {second}"), + }) + } } diff --git a/crates/ely_browser_core/tests/splits.rs b/crates/ely_browser_core/tests/splits.rs index 00c7599..d880155 100644 --- a/crates/ely_browser_core/tests/splits.rs +++ b/crates/ely_browser_core/tests/splits.rs @@ -42,6 +42,39 @@ fn split_right_command_focuses_new_pane() -> Result<(), Box> { Ok(()) } +#[test] +fn save_split_view_command_marks_active_layout() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.split_active_tab_right()?; + + core.set_command_query(">save-split-view"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let layout = snapshot.split_layouts.first().ok_or("missing split layout")?; + + assert_eq!(intent, Some(CommandIntent::Command("save-split-view".to_string()))); + assert!(layout.saved()); + assert_eq!(layout.title(), "Split View: New Tab + New Tab"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn save_split_view_command_preserves_query_without_active_split() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">save-split-view"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("save-split-view".to_string()))); + assert!(snapshot.split_layouts.is_empty()); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.command_query, ">save-split-view"); + Ok(()) +} + #[test] fn closing_split_pane_dissolves_two_pane_layout() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/split.rs b/crates/ely_domain/src/split.rs index 9e77911..f2d563c 100644 --- a/crates/ely_domain/src/split.rs +++ b/crates/ely_domain/src/split.rs @@ -37,12 +37,14 @@ pub struct SplitLayout { id: SplitId, axis: SplitAxis, panes: Vec, + title: String, + saved: bool, } impl SplitLayout { #[must_use] pub fn new(axis: SplitAxis, panes: Vec) -> Self { - Self { id: SplitId::new(), axis, panes } + Self { id: SplitId::new(), axis, panes, title: "Split View".to_string(), saved: false } } #[must_use] @@ -60,6 +62,16 @@ impl SplitLayout { &self.panes } + #[must_use] + pub fn title(&self) -> &str { + &self.title + } + + #[must_use] + pub fn saved(&self) -> bool { + self.saved + } + #[must_use] pub fn contains_tab(&self, tab_id: &TabId) -> bool { self.panes.iter().any(|pane| pane.tab_id() == tab_id) @@ -84,4 +96,9 @@ impl SplitLayout { self.panes.retain(|pane| pane.tab_id() != tab_id); self.panes.len() != original_len } + + pub fn save(&mut self, title: impl Into) { + self.title = title.into(); + self.saved = true; + } }