From bcbff199c92ca79bac9c405dd370e33cca3145db Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:51:29 +0200 Subject: [PATCH] feat(sidebar): draw pinned groups above a divider, with their own gestures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar now reads as two halves: the groups you keep, in the order you put them, then a divider, then the groups it works out (Arc-style). - Pinned headers drag-reorder among themselves (their own reorder surface, so a pinned header cannot be dropped among the derived ones); the order lands on the workspace's group list, not on the tabs. - An auto header carried above the divider is pinned when let go. With nothing pinned yet the divider appears during that drag as a "Drop here to pin" zone, since a hairline at the top of the list is nothing to aim at. - A tab kept in a pinned group and dropped anywhere below the divider goes back to auto grouping; the divider lights to say so. - An empty pinned group stays, with a "+ New Tab" row that opens a tab in its folder (or where ⌘T would, for a label group) and files it there. - Folder groups carry a pin mark that unpins on click and a tooltip with the folder; auto headers show pin and "+" on hover. - Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto — Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly. --- assets/icons/pin.svg | 1 + src/ui/assets.rs | 1 + src/ui/reorder.rs | 20 ++ src/ui/tab_sidebar.rs | 647 ++++++++++++++++++++++++++++++++++++++---- 4 files changed, 618 insertions(+), 51 deletions(-) create mode 100644 assets/icons/pin.svg diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 00000000..05568e0d --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 2bf9e9ab..787c49bc 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -34,6 +34,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), + "icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 7037bbd6..c5f8f940 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -547,6 +547,26 @@ mod tests { assert_eq!(take_landed(&state).pin, Some(key)); } + /// A finished drag says which surface its order is for: the pinned + /// headers' order is the list's, not the tabs'. + #[test] + fn a_landing_names_the_surface_its_order_is_for() { + let r = Reorder::new( + Surface::PinnedGroups, + 0, + column(3, 30., 2., 0).rects, + Axis::Vertical, + px(2.), + point(px(0.), px(0.)), + ); + let state: ReorderState = Rc::new(RefCell::new(Some(r))); + clear_pending(&state); + set_pending(&state, &Surface::PinnedGroups, vec![1, 0, 2]); + let landed = take_landed(&state); + assert_eq!(landed.surface, Some(Surface::PinnedGroups)); + assert_eq!(landed.order, Some(vec![1, 0, 2])); + } + /// Only an auto header can be pinned by dragging; a drag carrying /// anything else offers nothing. #[test] diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 66be2c53..a144a988 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -232,6 +232,9 @@ impl Tty7App { // same way a pane dropped here is. Blanked and written again below // so a group that folds or filters away stops accepting drops. let over_group = self.sidebar_regroup_target(window); + // Taken rather than read, for the same reason: last frame's divider is + // what a drag is measured against, and the one drawn below replaces it. + let divider_last = self.sidebar_divider.take(); let lifting_row = crate::ui::reorder::dragged_sidebar_tab(&self.reorder).is_some(); // Offered every frame the pointer is over a group, and cleared with // the rest of the drag's pending state on the frames it is not — so @@ -343,12 +346,45 @@ impl Tty7App { cx.theme().muted_foreground, cx.theme().sidebar, ); - let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); + // A pinned group is drawn with no tabs in it — it is kept, and the + // row it grows says how to fill it — except while a search is + // narrowing the list, where a group with nothing matching is noise. + let rendered = |ix: &usize| { + !visible_by_section[*ix].is_empty() + || (query.is_empty() && sections[*ix].pinned().is_some()) + }; + // Pinned headers reorder among themselves, by hand; that order is the + // workspace's and is written back whole when a drag lands. + let pinned_slots: Vec = (0..sections.len()) + .filter(|&ix| sections[ix].pinned().is_some()) + .filter(rendered) + .collect(); + let pinned_rects: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); pinned_slots.len()])); + let pinned_preview = reorder::preview( + &self.reorder, + &Surface::PinnedGroups, + pinned_slots.len(), + pointer, + ); + let pinned_display: Vec = match &pinned_preview { + Some(p) => { + let all: Vec = groups.pinned.iter().map(|g| g.id).collect(); + let shown: Vec = pinned_slots + .iter() + .filter_map(|&ix| sections[ix].pinned()) + .collect(); + if let Some(order) = reordered_pinned(&all, &shown, p.from, p.target) { + reorder::set_pending(&self.reorder, &Surface::PinnedGroups, order); + } + p.order.clone() + } + None => (0..pinned_slots.len()).collect(), + }; // Every auto group draws a header, and a header is what there is to - // grab, so these are the slots the group-reorder surface runs over. + // grab, so these are the slots the auto-group surface runs over. // Ungrouped is excluded: it is where the keyless tabs fall, and it - // always sits last. Pinned groups sit above all of them, in an order - // of their own. + // always sits last. let keyed_slots: Vec = (0..sections.len()) .filter(|&ix| { sections[ix] @@ -382,15 +418,15 @@ impl Tty7App { } None => (0..keyed_groups).collect(), }; - let mut blocks: Vec<(Option, usize)> = (0..sections.len()) - .filter(|&ix| sections[ix].pinned().is_some()) - .filter(rendered) - .map(|ix| (None, ix)) + let mut blocks: Vec<(Option, usize)> = pinned_display + .into_iter() + .map(|slot| (Some(HeaderSlot::Pinned(slot)), pinned_slots[slot])) .collect(); + let first_unpinned = blocks.len(); blocks.extend( slot_display .into_iter() - .map(|slot| (Some(slot), keyed_slots[slot])), + .map(|slot| (Some(HeaderSlot::Auto(slot)), keyed_slots[slot])), ); blocks.extend( (0..sections.len()) @@ -398,8 +434,38 @@ impl Tty7App { .filter(rendered) .map(|ix| (None, ix)), ); + // The divider: kept groups above it, derived ones below. Drawn + // whenever something is kept, and while an auto header is in the air + // even when nothing is yet — carrying it above the divider is how it + // gets pinned, so there has to be a divider to carry it above. + let lifted_auto = reorder::dragged_auto_group(&self.reorder); + let show_divider = !groups.pinned.is_empty() || lifted_auto.is_some(); + let pin_hover = lifted_auto.is_some() + && divider_last.is_some_and(|d| pointer.y < d.origin.y + d.size.height); + if pin_hover { + reorder::set_pin(&self.reorder); + } + let divider_lit = pin_hover || over_group == Some(reorder::Regroup::ToAuto); + let divider_zone = groups.pinned.is_empty(); + let mut divider_drawn = false; + // Whether the tab in the air is kept in a pinned group — then + // everything below the divider is somewhere it can go. + let lifted_is_kept = reorder::dragged_sidebar_tab(&self.reorder).is_some_and(|id| { + self.tabs + .iter() + .find(|t| t.tree_id.get() == id) + .and_then(|t| t.group.get()) + .is_some_and(|g| groups.contains(g)) + }); + let workspace_is_local = !crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); - for (group_slot, group_ix) in blocks { + for (n, (group_slot, group_ix)) in blocks.into_iter().enumerate() { + if n == first_unpinned && show_divider { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + divider_drawn = true; + } let section = §ions[group_ix]; let group_key = section.key.clone(); // Only a group that draws a header can be folded — there is @@ -1026,7 +1092,8 @@ impl Tty7App { })); } - if row_count == 0 { + // An empty pinned group is still drawn: it is kept until deleted. + if row_count == 0 && section.pinned().is_none() { continue; } @@ -1087,6 +1154,11 @@ impl Tty7App { // header identical to that repo's. let pinned_id = section.pinned(); let pinned = pinned_id.is_some(); + let pinned_folder: Option = pinned_id + .and_then(|id| groups.get(id)) + .and_then(|g| g.folder.clone()) + .map(SharedString::from); + let auto_key_here = group_key.as_ref().and_then(GroupKey::auto).cloned(); let renaming_group = self .group_rename .as_ref() @@ -1106,7 +1178,7 @@ impl Tty7App { // row already elides its own. let ts = window.text_system(); let mut avail = row_metrics::header_budget(width); - if pinned { + if pinned_folder.is_some() { avail -= row_metrics::HEADER_ICON + row_metrics::META_GAP; } let count_label = row_count.to_string(); @@ -1129,8 +1201,11 @@ impl Tty7App { let name_avail = header_name_avail(avail, git_want); let label = elide_label(&ts, &header_font, header_size, &name, name_avail); let name_w = measure_text(&ts, &header_font, header_size, &label); + let hover_group = SharedString::from(format!("sidebar-group-{group_ix}")); let bar = h_flex() .id(("sidebar-group", group_ix)) + .group(hover_group.clone()) + .relative() .w_full() .items_center() .gap_1p5() @@ -1149,19 +1224,31 @@ impl Tty7App { move |this, _, _window, cx| this.toggle_sidebar_group(key.as_ref(), cx) })) .when_some(group_slot, |header, slot| { + let (surface, slot, slots) = match slot { + HeaderSlot::Pinned(s) => { + (Surface::PinnedGroups, s, pinned_rects.clone()) + } + HeaderSlot::Auto(s) => (Surface::SidebarGroups, s, group_slots.clone()), + }; + let auto = auto_key_here.clone(); crate::ui::reorder::cursor_grab(header).on_drag(DragGroup, { let state = self.reorder.clone(); - let slots = group_slots.clone(); move |_drag, grab, _window, cx| { cx.stop_propagation(); - *state.borrow_mut() = Some(Reorder::new( - Surface::SidebarGroups, + let reorder = Reorder::new( + surface.clone(), slot, slots.borrow().clone(), Axis::Vertical, px(ROW_GAP), grab, - )); + ); + // An auto header says which group it is, so + // letting it go above the divider can pin it. + *state.borrow_mut() = Some(match auto.clone() { + Some(key) => reorder.of_auto(key), + None => reorder, + }); cx.new(|_| DragGroup) } }) @@ -1176,13 +1263,37 @@ impl Tty7App { .child(Icon::new(IconName::ChevronRight).xsmall()), ) }) - .when(pinned, |header| { - header.child( - div() - .flex_shrink_0() - .child(Icon::new(IconName::Asterisk).xsmall()), - ) - }) + // The pin marks a folder group: kept, and keeping a folder. + // It is also the way to stop keeping it — a click unpins, + // and the folder's tabs fall back to the groups their cwds + // resolve to. A label group has no such mark: it sits above + // the divider, and there is no folder to fall back on. + .when_some( + pinned_id.filter(|_| pinned_folder.is_some()), + |header, id| { + header.child( + div() + .id(("sidebar-group-unpin", group_ix)) + .flex_shrink_0() + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .child(Icon::empty().path("icons/pin.svg").xsmall()) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t( + L10nKey::SidebarUnpinGroup, + )) + .build(window, cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.delete_group(id, cx); + })), + ) + }, + ) .child(match renaming_group { Some(input) => div() .id(("sidebar-group-rename", group_ix)) @@ -1200,6 +1311,7 @@ impl Tty7App { // backstop for a face that measures wider than it // paints; the name no longer gives room to the branch. None => div() + .id(("sidebar-group-name", group_ix)) .flex_shrink_0() .min_w_0() .truncate() @@ -1209,6 +1321,14 @@ impl Tty7App { // counts beside it are the metadata. .text_color(cx.theme().foreground) .child(label) + // A folder group may have been renamed to anything; + // the folder it keeps is what the name stands for. + .when_some(pinned_folder.clone(), |name, folder| { + name.tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(folder.clone()) + .build(window, cx) + }) + }) .into_any_element(), }) .when_some(shared_git, |bar, shared| { @@ -1298,32 +1418,41 @@ impl Tty7App { // is what a shut group has instead of them. .when(folded, |bar| { bar.child(div().flex_shrink_0().child(count_label)) - }); + }) + .children(self.header_actions(group_ix, group_key.clone(), hover_group, cx)); // Renaming is offered on a menu rather than a double click: // the first click of a double would fold the group, so the - // name would be edited on a box that just shut. A repo group - // gets no menu — it is named after its root, and a rename - // there could only lie about where its tabs are. + // name would be edited on a box that just shut. An auto group + // offers no rename — it is named after its repo or host, and a + // rename there could only lie about where its tabs are; pin it + // first, and it is the user's to name. // // Attached last and erased to `AnyElement`, because the menu - // wrapper changes the element's type and the two arms have to + // wrapper changes the element's type and the arms have to // agree. - match pinned_id { - Some(id) => { - let app = cx.entity().downgrade(); - bar.context_menu(move |menu, _window, _cx| { - let app = app.clone(); - menu.item(PopupMenuItem::new(t(L10nKey::SidebarRenameGroup)).on_click( - move |_, window, cx| { - let _ = app.update(cx, |this, cx| { - this.start_group_rename(id, window, cx) - }); - }, - )) + let app = cx.entity().downgrade(); + match (pinned_id, auto_key_here.clone()) { + (Some(id), _) => { + let has_folder = pinned_folder.is_some(); + bar.context_menu(move |menu, window, cx| { + pinned_header_menu( + menu, + id, + has_folder, + workspace_is_local, + &app, + window, + cx, + ) }) .into_any_element() } - None => bar.into_any_element(), + (None, Some(key)) => bar + .context_menu(move |menu, _window, _cx| { + auto_header_menu(menu, key.clone(), &app) + }) + .into_any_element(), + (None, None) => bar.into_any_element(), } }); @@ -1332,16 +1461,53 @@ impl Tty7App { // that cannot fade back. Until a drag is under way they look // alike, and this is where a user finds out which is which // without being told. - let takes_drops = pinned; + let takes_drops = pinned || (lifted_is_kept && n >= first_unpinned); + let (preview, slot) = match group_slot { + Some(HeaderSlot::Pinned(s)) => (pinned_preview.as_ref(), Some(s)), + Some(HeaderSlot::Auto(s)) => (group_preview.as_ref(), Some(s)), + None => (None, None), + }; + // An empty pinned group says how to fill it, in the place its + // rows would be: a tab can be dragged onto it, or opened here. + let empty_row = (row_count == 0 && !folded) + .then_some(pinned_id) + .flatten() + .map(|id| { + h_flex() + .id(("sidebar-group-empty", group_ix)) + .w_full() + .h(px(ROW_HEIGHT)) + .items_center() + .gap_2() + .pl_2() + .rounded(crate::ui::rounding::CARD_RADIUS) + .cursor_pointer() + .text_sm() + .text_color(cx.theme().muted_foreground) + .hover(|s| { + s.bg(gpui::rgb(sf.hover)) + .text_color(cx.theme().sidebar_foreground) + }) + .child( + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(row_metrics::AVATAR)) + .child(Icon::empty().path("icons/plus.svg").xsmall()), + ) + .child(t(L10nKey::SidebarGroupNewTab)) + .on_click(cx.listener(move |this, _, window, cx| { + this.new_tab_in_group(GroupKey::Pinned(id), window, cx) + })) + }); let block = v_flex() .w_full() .gap(px(ROW_GAP)) - .when( - group_preview - .as_ref() - .is_some_and(|p| Some(p.from) == group_slot), - |b| b.opacity(0.75), - ) + .when(preview.is_some_and(|p| Some(p.from) == slot), |b| { + b.opacity(0.75) + }) .when(lifting_row && !takes_drops, |b| b.opacity(0.4)) .when( pinned_id.is_some_and(|id| over_group == Some(reorder::Regroup::Into(id))), @@ -1349,16 +1515,20 @@ impl Tty7App { ) .children(header) .children(rows) + .children(empty_row) .child( canvas( { - let slots = group_slots.clone(); + let slots = match group_slot { + Some(HeaderSlot::Pinned(_)) => pinned_rects.clone(), + _ => group_slots.clone(), + }; let landing = self.sidebar_group_slots.clone(); // Only a pinned group is recorded, so a drag // looking for somewhere to land finds nothing over // an auto group or over Ungrouped. move |bounds, _window, _cx| { - if let Some(slot) = group_slot + if let Some(slot) = slot && let Some(s) = slots.borrow_mut().get_mut(slot) { *s = bounds; @@ -1375,7 +1545,7 @@ impl Tty7App { ); any_rows = true; - list = list.child(match (&group_preview, group_slot) { + list = list.child(match (preview, slot) { (Some(p), Some(slot)) if p.from == slot => { deferred(block.relative().top(p.held)).into_any_element() } @@ -1396,6 +1566,9 @@ impl Tty7App { _ => block.into_any_element(), }); } + if show_divider && !divider_drawn { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + } if !any_rows && !query.is_empty() { list = list.child( @@ -1630,6 +1803,142 @@ impl Tty7App { .child(handle) } + /// The line between the kept groups and the derived ones, recording where + /// it was drawn so a drag next frame can tell which side it is on. + /// + /// With nothing pinned yet it only appears while an auto header is being + /// carried, and then as a drop zone rather than a hairline: a one-pixel + /// line at the very top of the list is not something anyone could aim + /// above. `lit` while letting go would pin the header, or hand a kept tab + /// back to auto grouping. + fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context) -> AnyElement { + let bounds = self.sidebar_divider.clone(); + let ink = match lit { + true => cx.theme().drag_border, + false => cx.theme().sidebar_border, + }; + let body = match zone { + true => h_flex() + .h(px(ROW_HEIGHT)) + .w_full() + .items_center() + .justify_center() + .rounded(crate::ui::rounding::CARD_RADIUS) + .border_1() + .border_color(ink) + .when(lit, |d| d.bg(cx.theme().drag_border.opacity(0.15))) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::SidebarDropToPin)) + .into_any_element(), + false => div() + .w_full() + .h(px(if lit { 2. } else { 1. })) + .bg(ink) + .into_any_element(), + }; + v_flex() + .id("sidebar-divider") + .relative() + .w_full() + .px_2() + .pt(px(10.)) + .pb(px(2.)) + .child( + canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .child(body) + .into_any_element() + } + + /// The buttons a header shows on hover, laid over its right end: pin for + /// an auto group, and "+" to open a tab in the group. `None` for + /// Ungrouped, which has neither. + /// + /// Laid over the branch rather than beside it, the way a row's close + /// button covers its badge, so a header does not give up width it would + /// only use while the pointer is on it. Acted on at the press, as a row's + /// diff counts are: the header folds on click and picks itself up on a + /// drag, and swallowing the press is what keeps both from happening too. + fn header_actions( + &self, + group_ix: usize, + key: Option, + hover: SharedString, + cx: &mut Context, + ) -> Option { + let key = key?; + let backing = crate::ui::theme::workspace_surface_color(cx); + let mut fade = backing; + fade.a = 0.; + let muted = cx.theme().muted_foreground; + let ink = cx.theme().foreground; + let button = |id: &'static str, path: &'static str, tip: L10nKey| { + div() + .id((id, group_ix)) + .flex_shrink_0() + .p(px(2.)) + .rounded_sm() + .cursor_pointer() + .text_color(muted) + .hover(move |s| s.text_color(ink)) + .child(Icon::empty().path(path).xsmall()) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) + }) + }; + let mut buttons = h_flex().gap_0p5().bg(backing); + if let Some(auto) = key.auto().cloned() { + buttons = buttons.child( + button( + "sidebar-group-pin", + "icons/pin.svg", + L10nKey::SidebarPinGroup, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + this.pin_auto_group(auto.clone(), cx); + }), + ), + ); + } + if !matches!(key, GroupKey::Auto(AutoKey::SshHost(_))) { + buttons = buttons.child( + button( + "sidebar-group-add", + "icons/plus.svg", + L10nKey::SidebarGroupNewTab, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.new_tab_in_group(key.clone(), window, cx); + }), + ), + ); + } + Some( + h_flex() + .absolute() + .right(px(4.)) + .bottom(px(2.)) + .opacity(0.) + .group_hover(hover, |s| s.opacity(1.)) + .child(div().w(px(10.)).h(px(16.)).bg(linear_gradient( + 90., + linear_color_stop(fade, 0.), + linear_color_stop(backing, 1.), + ))) + .child(buttons) + .into_any_element(), + ) + } + /// What the sidebar row hid: the full title, the full branch and diff /// counts, the working directory, and the remote host the avatar only /// dots. `None` when the row showed everything — a card would add noise, @@ -2240,6 +2549,125 @@ impl Tty7App { } } +/// A header that can be picked up, and which of the two orders it belongs to. +#[derive(Clone, Copy)] +enum HeaderSlot { + /// Slot `n` among the pinned headers, above the divider. + Pinned(usize), + /// Slot `n` among the auto headers, below it. + Auto(usize), +} + +/// One item of a group header's menu, acting on the app when it is chosen. +fn menu_item( + label: L10nKey, + app: &gpui::WeakEntity, + act: impl Fn(&mut Tty7App, &mut Window, &mut Context) + 'static, +) -> PopupMenuItem { + let app = app.clone(); + PopupMenuItem::new(t(label)).on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| act(this, window, cx)); + }) +} + +/// A pinned group's header menu. Everything about a kept group is the +/// user's to change: its name, the folder it keeps, and whether it is kept at +/// all. Deleting closes nothing — its tabs go back to auto grouping. +/// +/// "Set Folder…" asks the system picker, which browses this computer, so it +/// is only offered for a workspace on it; "Use Current Tab's Folder" works +/// anywhere, since the tab's cwd is on the workspace's own host. +fn pinned_header_menu( + menu: gpui_component::menu::PopupMenu, + id: GroupId, + has_folder: bool, + local: bool, + app: &gpui::WeakEntity, + window: &Window, + cx: &gpui::App, +) -> gpui_component::menu::PopupMenu { + let current = app + .upgrade() + .and_then(|a| a.read(cx).active_tab_folder(window, cx)); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarRenameGroup, + app, + move |this, w, cx| this.start_group_rename(id, w, cx), + )); + if local { + menu = menu.item(menu_item( + L10nKey::SidebarSetGroupFolder, + app, + move |this, _, cx| this.pick_group_folder(id, cx), + )); + } + menu = menu.item( + menu_item( + L10nKey::SidebarUseCurrentTabFolder, + app, + move |this, w, cx| { + if let Some(folder) = this.active_tab_folder(w, cx) { + this.set_group_folder(id, Some(folder), cx); + } + }, + ) + .disabled(current.is_none()), + ); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarClearGroupFolder, + app, + move |this, _, cx| this.set_group_folder(id, None, cx), + )); + } + menu = menu + .separator() + .item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Pinned(id), w, cx), + )) + .separator(); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarUnpinGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )); + } + menu.item(menu_item( + L10nKey::SidebarDeleteGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )) +} + +/// An auto group's header menu: pin it, or open a tab in it. Nothing else — +/// an auto group is derived, so there is nothing about it to edit until it +/// is pinned, and pinning is always an explicit act. +fn auto_header_menu( + menu: gpui_component::menu::PopupMenu, + key: AutoKey, + app: &gpui::WeakEntity, +) -> gpui_component::menu::PopupMenu { + let pin = key.clone(); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarPinGroup, + app, + move |this, _, cx| this.pin_auto_group(pin.clone(), cx), + )); + // A host group has no directory to open a tab in, and no way to open a + // shell on the host that would not guess at how the others got there. + if let AutoKey::Repo(_) = &key { + menu = menu.item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Auto(key.clone()), w, cx), + )); + } + menu +} + /// Where a tab about to be spawned goes, worked out before it exists — see /// [`Tty7App::spawn_group`]. pub(crate) struct SpawnPlace { @@ -3268,6 +3696,123 @@ mod fold_tests { assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back"); }); } + /// The divider stands between kept and derived groups, so it is there + /// exactly when something is kept — and it is recorded, because a tab + /// dropped below it leaves its pinned group. + #[gpui::test] + fn the_divider_is_drawn_once_something_is_pinned(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + app.update(&mut vcx, |_, cx| cx.notify()); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_none(), "nothing kept, no line"); + }); + + app.update(&mut vcx, |app, cx| { + label(app, "work", cx); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_some(), "kept, so a line"); + }); + } + + /// An empty pinned group stays on screen, and takes drops like any other: + /// its block is recorded where a dragged tab looks for somewhere to land. + #[gpui::test] + fn an_empty_pinned_group_is_drawn_and_takes_drops(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let work = app.update(&mut vcx, |app, cx| label(app, "work", cx)); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + let landing = app.sidebar_group_slots.borrow(); + let block = landing + .iter() + .find(|(id, _)| *id == work) + .expect("the empty group was drawn"); + assert!(block.1.size.height > px(0.)); + }); + } + + /// Pinned headers are ordered by hand; a drag lands as a new order for + /// the list, not for the tabs. + #[gpui::test] + fn a_pinned_drag_reorders_the_groups_not_the_tabs(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update(&mut vcx, |app, cx| { + let (a, b, c) = ( + label(app, "a", cx), + label(app, "b", cx), + label(app, "c", cx), + ); + app.set_tab_group(0, Some(a), cx); + app.set_tab_group(1, Some(c), cx); + let tabs: Vec<_> = app.tabs.iter().map(|t| t.tree_id.get()).collect(); + app.apply_pinned_order(&[2, 0, 1], cx); + assert_eq!( + app.pinned_group_names() + .into_iter() + .map(|(id, _)| id) + .collect::>(), + vec![c, a, b] + ); + assert_eq!( + app.tabs.iter().map(|t| t.tree_id.get()).collect::>(), + tabs, + "the tabs stayed where they were" + ); + // An order that is not a permutation of the list changes nothing. + app.apply_pinned_order(&[0, 1], cx); + assert_eq!(app.sidebar_groups.pinned.len(), 3); + }); + } + + /// "New Tab" on a pinned group opens a tab in it — joining outright, + /// without waiting for a cwd to walk in, since a label group has nothing + /// to walk into. + #[gpui::test] + fn a_label_group_can_be_filled_from_its_header(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let before = app.update(&mut vcx, |app, _| app.tabs.len()); + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + app.new_tab_in_group(GroupKey::Pinned(work), window, cx); + work + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + // A harness without a daemon may refuse to spawn; when it does + // open one, it has to be in the group. + if app.tabs.len() > before { + assert_eq!(app.tabs[app.active].group.get(), Some(work)); + } + }); + } + + /// Clearing a folder keeps the name the header was showing: a folder + /// group nobody renamed reads its folder's name, and would read nothing + /// once the folder is gone. + #[gpui::test] + fn clearing_a_folder_keeps_the_name_it_was_showing(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + app.set_group_folder(tty7, None, cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder, None); + assert_eq!(group.given_name(), Some("tty7")); + app.set_group_folder(tty7, Some(PathBuf::from("/w/else")), cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder.as_deref(), Some("/w/else")); + assert_eq!(group.given_name(), Some("tty7"), "a set name stays"); + }); + } } #[cfg(test)]